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); }