diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor
index 675eef3d..a65c2607 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor
@@ -8,6 +8,10 @@
+ @* T34b: dark-mode toggle sits in the rail footer alongside the session
+ block. It is auth-agnostic (pure client-side theme) so it renders even
+ on the login page. *@
+
@* CentralUI-024: claim type resolved via JwtTokenService. *@
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor
new file mode 100644
index 00000000..42b2b3b5
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor
@@ -0,0 +1,54 @@
+@inject IJSRuntime JS
+
+@* T34b: dark-mode toggle. Pure JS-interop + localStorage — no DI service and no
+ server state. The actual theme switch happens in the browser via window.sbTheme
+ (wwwroot/js/theme.js); this button just drives toggle() and reflects the result.
+ The page-load default (no-flash) is owned by the inline pre-hydration script in
+ App.razor, not here. *@
+
+
+@code {
+ /// Mirrors the browser's current theme so the glyph + aria-pressed stay in sync.
+ private bool _isDark;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender) return;
+
+ // Reflect the persisted theme once the circuit is interactive. Prerender
+ // has no JS runtime, so guard the call; a disconnect/blip just leaves the
+ // button on its default (light) glyph until the next render.
+ try
+ {
+ var mode = await JS.InvokeAsync("sbTheme.get");
+ var isDark = mode == "dark";
+ if (isDark != _isDark)
+ {
+ _isDark = isDark;
+ StateHasChanged();
+ }
+ }
+ catch (JSException) { }
+ catch (JSDisconnectedException) { }
+ catch (InvalidOperationException) { }
+ }
+
+ private async Task ToggleAsync()
+ {
+ try
+ {
+ var mode = await JS.InvokeAsync("sbTheme.toggle");
+ _isDark = mode == "dark";
+ }
+ catch (JSException) { }
+ catch (JSDisconnectedException) { }
+ catch (InvalidOperationException) { }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js
new file mode 100644
index 00000000..e40fa19e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js
@@ -0,0 +1,32 @@
+// Dark-mode theme helper (T34b). Plain browser global in the treeview-storage.js
+// style (no ES-module export) so the App.razor pre-hydration inline
+// script and this file agree on one localStorage key and one apply rule.
+//
+// * get() — reads the persisted choice ('dark' only when explicitly stored;
+// anything else is 'light').
+// * apply() — sets data-bs-theme on ; the site.css [data-bs-theme="dark"]
+// token-override block (T34a) does the rest, side-rail included.
+// * set() — persists + applies, returns the mode.
+// * toggle() — flips and persists, returns the new mode (the DarkModeToggle
+// component's click path).
+//
+// Every localStorage touch is wrapped in try/catch so private-browsing modes
+// (which throw on access) degrade to light rather than break the page.
+window.sbTheme = {
+ KEY: 'sb-theme',
+ get() {
+ try { return localStorage.getItem(this.KEY) === 'dark' ? 'dark' : 'light'; }
+ catch { return 'light'; }
+ },
+ apply(mode) {
+ document.documentElement.setAttribute('data-bs-theme', mode);
+ },
+ set(mode) {
+ try { localStorage.setItem(this.KEY, mode); } catch { }
+ this.apply(mode);
+ return mode;
+ },
+ toggle() {
+ return this.set(this.get() === 'dark' ? 'light' : 'dark');
+ }
+};
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
index eb515924..54d7204c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
@@ -10,6 +10,19 @@
+
@@ -78,6 +91,7 @@
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs
new file mode 100644
index 00000000..b45ce31c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs
@@ -0,0 +1,85 @@
+using Bunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
+
+///
+/// Tests for T34b's . The component is pure
+/// JS-interop over window.sbTheme (theme.js): sbTheme.get reflects
+/// the persisted theme on first render and sbTheme.toggle flips it on
+/// click. The toggle carries the required a11y attributes
+/// (aria-label="Toggle dark mode" + aria-pressed).
+///
+public class DarkModeToggleTests : BunitContext
+{
+ public DarkModeToggleTests()
+ {
+ // Loose mode: unconfigured interop returns default. We configure the two
+ // calls the component actually makes (get on first render, toggle on click).
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ }
+
+ [Fact]
+ public void Renders_Button_WithToggleDarkModeAriaLabel()
+ {
+ JSInterop.Setup("sbTheme.get").SetResult("light");
+
+ var cut = Render();
+
+ var button = cut.Find("button[aria-label='Toggle dark mode']");
+ Assert.NotNull(button);
+ }
+
+ [Fact]
+ public void OnFirstRender_PersistedLight_AriaPressedFalse()
+ {
+ // sbTheme.get returns light → button reflects light (aria-pressed=false,
+ // moon glyph offering "switch to dark").
+ JSInterop.Setup("sbTheme.get").SetResult("light");
+
+ var cut = Render();
+
+ var button = cut.Find("button.sb-theme-toggle");
+ Assert.Equal("false", button.GetAttribute("aria-pressed"));
+ Assert.NotNull(cut.Find("i.bi-moon-stars"));
+ }
+
+ [Fact]
+ public void OnFirstRender_PersistedDark_AriaPressedTrue()
+ {
+ // sbTheme.get returns dark → button reflects dark (aria-pressed=true,
+ // sun glyph offering "switch to light").
+ JSInterop.Setup("sbTheme.get").SetResult("dark");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ var button = cut.Find("button.sb-theme-toggle");
+ Assert.Equal("true", button.GetAttribute("aria-pressed"));
+ Assert.NotNull(cut.Find("i.bi-sun"));
+ });
+ }
+
+ [Fact]
+ public void Click_InvokesToggle_AndReflectsNewState()
+ {
+ // Start persisted-light; toggling returns "dark". The click handler must
+ // invoke sbTheme.toggle and flip aria-pressed/glyph to the dark state.
+ JSInterop.Setup("sbTheme.get").SetResult("light");
+ var toggle = JSInterop.Setup("sbTheme.toggle").SetResult("dark");
+
+ var cut = Render();
+
+ cut.Find("button.sb-theme-toggle").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ // The handler invoked sbTheme.toggle.
+ Assert.Single(toggle.Invocations);
+ var button = cut.Find("button.sb-theme-toggle");
+ Assert.Equal("true", button.GetAttribute("aria-pressed"));
+ Assert.NotNull(cut.Find("i.bi-sun"));
+ });
+ }
+}