diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor
index 1905573..bdfc432 100644
--- a/DeepDrftPublic.Client/Layout/MainLayout.razor
+++ b/DeepDrftPublic.Client/Layout/MainLayout.razor
@@ -6,6 +6,7 @@
@using Microsoft.AspNetCore.Components
@inherits LayoutComponentBase
@implements IDisposable
+@implements IAsyncDisposable
@@ -42,10 +43,13 @@
private string _audioPlayerClass = "minimized";
private const string DarkModeKey = "darkMode";
private bool _isDarkMode = false;
+ private bool? _lastAppliedDarkMode = null;
private PersistingComponentStateSubscription _persistingSubscription;
+ private IJSObjectReference? _themeModule;
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
+ [Inject] public required IJSRuntime JS { get; set; }
protected override void OnInitialized()
{
@@ -66,6 +70,24 @@
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
}
+ // Sync dark mode class on
so portaled MudBlazor elements (popovers, menus, selects)
+ // inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than from :root only.
+ // Popovers portal outside the ThemeWrapperClass div, so only a body-level class can reach them.
+ // Gated: only fires on first render or when _isDarkMode actually changes, to avoid redundant
+ // JS calls on unrelated re-renders (e.g. audio player minimize/expand).
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+
+ if (firstRender || _isDarkMode != _lastAppliedDarkMode)
+ {
+ _lastAppliedDarkMode = _isDarkMode;
+ _themeModule ??= await JS.InvokeAsync(
+ "import", "./_content/DeepDrftShared.Client/js/theme/theme.js");
+ await _themeModule.InvokeVoidAsync("setBodyThemeClass", _isDarkMode);
+ }
+ }
+
// Theme wrapper class for CSS targeting
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
@@ -80,6 +102,15 @@
_persistingSubscription.Dispose();
}
+ public async ValueTask DisposeAsync()
+ {
+ if (_themeModule != null)
+ {
+ try { await _themeModule.DisposeAsync(); }
+ catch (JSDisconnectedException) { /* circuit torn down */ }
+ }
+ }
+
private void ToggleAudioPlayerMinimized(bool isMinimized)
{
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
diff --git a/DeepDrftShared.Client/Interop/theme/theme.ts b/DeepDrftShared.Client/Interop/theme/theme.ts
new file mode 100644
index 0000000..1566099
--- /dev/null
+++ b/DeepDrftShared.Client/Interop/theme/theme.ts
@@ -0,0 +1,15 @@
+/**
+ * theme - body-class helpers for dark-mode theme toggling.
+ *
+ * Single Responsibility: apply or remove the deepdrft-theme-dark class on
+ * document.body so that portaled MudBlazor elements (popovers, menus, selects)
+ * inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
+ * from :root only. Popovers portal outside the ThemeWrapperClass div, so only
+ * a body-level class can reach them.
+ */
+
+/** Toggle the deepdrft-theme-dark class on document.body.
+ * @param isDark true to add the class, false to remove it. */
+export function setBodyThemeClass(isDark: boolean): void {
+ document.body.classList.toggle('deepdrft-theme-dark', isDark);
+}
diff --git a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
index c1a89a0..3a08ea4 100644
--- a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
+++ b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
@@ -85,10 +85,15 @@
--deepdrft-play-chip-soft: var(--deepdrft-soft);
/* Popover surface (Phase 18). Default MudBlazor popovers (selects/menus/tooltips/share
- body) bind this. Light is a soft desaturated-navy wash so they read as a calm light
- surface; dark uses the existing panel-ground charcoal. Bespoke dark-glass panels
- (visualizer/queue/privacy) do NOT bind this — they keep --deepdrft-panel-ground directly. */
- --deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white));
+ body) bind this. Light uses a very subtle navy wash (4%) — near the page background but
+ just perceptibly off-white so the popover reads as an elevated surface. Dark uses a
+ bluer navy (colour-mix of navy-mid + green-accent at 20%), defined once in
+ --deepdrft-popover-surface-dark below and referenced by both the .deepdrft-theme-dark
+ wrapper block and the body.deepdrft-theme-dark block so portaled popover content (which
+ portals to , outside the wrapper div) is also reached. Bespoke dark-glass panels
+ (visualizer/queue/privacy) do NOT bind this — they keep --deepdrft-panel-ground. */
+ --deepdrft-popover-surface-dark: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%);
+ --deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy) 4%, var(--deepdrft-white));
/* Fixed-nav height — single source of truth shared by the frosted-glass nav
(DeepDrftMenu.razor.css pins .dd-nav to this) and the main-content clearance
@@ -163,9 +168,17 @@
--deepdrft-play-glyph: var(--deepdrft-navy);
--deepdrft-play-chip-soft: color-mix(in srgb, var(--deepdrft-green-accent) 30%, transparent);
- /* Popover surface (Phase 18). Symptom #1 is a LIGHT-mode complaint and the acceptance bar
- requires dark popovers to stay unchanged, so dark binds the exact current MudBlazor dark
- Surface (#162437, DeepDrftPalettes.Dark.Surface) — NOT §3's suggested panel-ground (#1a1c22),
- which would have shifted dark popovers a shade. Pixel-identical dark; only light is retoned. */
- --deepdrft-popover-surface: #162437;
+ /* Popover surface (Phase 18) — within .deepdrft-theme-dark wrapper this value applies to
+ non-portaled elements only (drawers, inline menus). Portaled MudBlazor popovers live at
+ level; the body.deepdrft-theme-dark block below uses the same source token. */
+ --deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
+}
+
+/* Portal-scope dark popover surface. MudBlazor popovers (selects, menus, share body) portal
+ to , placing them outside the .deepdrft-theme-dark wrapper div. MainLayout.razor syncs
+ deepdrft-theme-dark onto via JS after each render, so this selector reaches portaled
+ content. Resolved from --deepdrft-popover-surface-dark (defined in :root above) — bluer navy
+ (navy-mid + 20% green-accent tint) rather than the pure charcoal #162437. */
+body.deepdrft-theme-dark {
+ --deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
}