From b5106d090f85705289a318c5b2064ee728ece50e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 20 Jun 2026 00:15:42 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20popover=20surface=20=E2=80=94=20body?= =?UTF-8?q?-class=20bridge=20for=20portal=20scope,=20retune=20light/dark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MudBlazor popovers portal to , outside the theme wrapper, so the dark token was unreachable. MainLayout now stamps deepdrft-theme-dark on . Light: 8%->4% navy (near page background); dark: navy-mid + 20% green-accent (bluer). --- DeepDrftPublic.Client/Layout/MainLayout.razor | 10 +++++++ .../wwwroot/styles/deepdrft-tokens.css | 30 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index 1905573..d201029 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor @@ -46,6 +46,7 @@ [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 +67,15 @@ _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. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("eval", + $"document.body.classList.toggle('deepdrft-theme-dark', {_isDarkMode.ToString().ToLower()})"); + } + // Theme wrapper class for CSS targeting private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light"; diff --git a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css index c1a89a0..dfcf604 100644 --- a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css +++ b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css @@ -85,10 +85,13 @@ --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 in the + body.deepdrft-theme-dark block below so it reaches portaled popover content (popovers + portal to , outside the .deepdrft-theme-dark wrapper div). Bespoke dark-glass + panels (visualizer/queue/privacy) do NOT bind this — they keep --deepdrft-panel-ground. */ + --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 +166,18 @@ --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. Portaled MudBlazor popovers live at level; the + body.deepdrft-theme-dark block below is the authoritative dark value for those. Keep + this in sync with that block for non-portaled surfaces (drawers, inline menus). */ + --deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%); +} + +/* 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. The value mirrors the .deepdrft-theme-dark block above — bluer navy + (navy-mid + 20% green-accent tint) rather than the pure charcoal #162437. */ +body.deepdrft-theme-dark { + --deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%); } From 30999b038cfbcddc83d35aab41586d915da46f0d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 20 Jun 2026 00:21:53 -0400 Subject: [PATCH 2/3] fix: gate OnAfterRenderAsync body-class JS call; hoist dark popover token Only stamps body class on firstRender or _isDarkMode change; adds base call. Hoists duplicate dark popover mix value to --deepdrft-popover-surface-dark in :root; both .deepdrft-theme-dark and body.deepdrft-theme-dark reference it via var(). --- DeepDrftPublic.Client/Layout/MainLayout.razor | 13 ++++++++++-- .../wwwroot/styles/deepdrft-tokens.css | 21 ++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index d201029..38dca39 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor @@ -42,6 +42,7 @@ private string _audioPlayerClass = "minimized"; private const string DarkModeKey = "darkMode"; private bool _isDarkMode = false; + private bool? _lastAppliedDarkMode = null; private PersistingComponentStateSubscription _persistingSubscription; [Inject] public required PersistentComponentState PersistentState { get; set; } @@ -70,10 +71,18 @@ // 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 JS.InvokeVoidAsync("eval", - $"document.body.classList.toggle('deepdrft-theme-dark', {_isDarkMode.ToString().ToLower()})"); + await base.OnAfterRenderAsync(firstRender); + + if (firstRender || _isDarkMode != _lastAppliedDarkMode) + { + _lastAppliedDarkMode = _isDarkMode; + await JS.InvokeVoidAsync("eval", + $"document.body.classList.toggle('deepdrft-theme-dark', {_isDarkMode.ToString().ToLower()})"); + } } // Theme wrapper class for CSS targeting diff --git a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css index dfcf604..3a08ea4 100644 --- a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css +++ b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css @@ -87,10 +87,12 @@ /* Popover surface (Phase 18). Default MudBlazor popovers (selects/menus/tooltips/share 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 in the - body.deepdrft-theme-dark block below so it reaches portaled popover content (popovers - portal to , outside the .deepdrft-theme-dark wrapper div). Bespoke dark-glass - panels (visualizer/queue/privacy) do NOT bind this — they keep --deepdrft-panel-ground. */ + 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 @@ -167,17 +169,16 @@ --deepdrft-play-chip-soft: color-mix(in srgb, var(--deepdrft-green-accent) 30%, transparent); /* Popover surface (Phase 18) — within .deepdrft-theme-dark wrapper this value applies to - non-portaled elements only. Portaled MudBlazor popovers live at level; the - body.deepdrft-theme-dark block below is the authoritative dark value for those. Keep - this in sync with that block for non-portaled surfaces (drawers, inline menus). */ - --deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%); + 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. The value mirrors the .deepdrft-theme-dark block above — bluer navy + 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: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%); + --deepdrft-popover-surface: var(--deepdrft-popover-surface-dark); } From 2591710f095fe3a739408bc17b9fd3fabcf0c49e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 20 Jun 2026 00:26:52 -0400 Subject: [PATCH 3/3] refactor: replace eval dark-mode body-class with TS theme interop helper Extracts setBodyThemeClass into DeepDrftShared.Client/Interop/theme/theme.ts; MainLayout lazy-imports the compiled module and calls it, matching the established knob/parallax IJSObjectReference pattern. DisposeAsync added. --- DeepDrftPublic.Client/Layout/MainLayout.razor | 16 ++++++++++++++-- DeepDrftShared.Client/Interop/theme/theme.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 DeepDrftShared.Client/Interop/theme/theme.ts diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index 38dca39..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 @@ -44,6 +45,7 @@ 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; } @@ -80,8 +82,9 @@ if (firstRender || _isDarkMode != _lastAppliedDarkMode) { _lastAppliedDarkMode = _isDarkMode; - await JS.InvokeVoidAsync("eval", - $"document.body.classList.toggle('deepdrft-theme-dark', {_isDarkMode.ToString().ToLower()})"); + _themeModule ??= await JS.InvokeAsync( + "import", "./_content/DeepDrftShared.Client/js/theme/theme.js"); + await _themeModule.InvokeVoidAsync("setBodyThemeClass", _isDarkMode); } } @@ -99,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); +}