Skip to content

Commit 6c7b1c4

Browse files
bruno-garciaclaude
andauthored
fix: sync theme with OS preference change and Sentry feedback widget (#448)
* fix: sync theme with OS preference change and Sentry feedback widget The website theme didn't update automatically when the OS appearance changed. Make OnSystemPreferenceChanged call ApplyTheme directly instead of relying solely on the async void event handler chain which could silently fail. Port the Sentry feedback widget CSS overrides from the Angular app (commit 0857de4) to the Blazor app.css so the widget follows the app's body.light-theme / body.dark-theme classes rather than always using its built-in colorScheme: "system" media query. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * address review: use InvokeAsync(StateHasChanged) for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add ThemeToggle component tests and simplify OnSystemPreferenceChanged Remove redundant StateHasChanged call from OnSystemPreferenceChanged since JSInvokable methods returning Task auto-trigger re-rendering, matching the Toggle() pattern. Add bUnit tests covering system preference changes, manual toggle cycling, and icon rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de245f1 commit 6c7b1c4

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

src/NuGetTrends.Web.Client/Shared/ThemeToggle.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@
5656
}
5757

5858
[JSInvokable]
59-
public void OnSystemPreferenceChanged(bool prefersDark)
59+
public async Task OnSystemPreferenceChanged(bool prefersDark)
6060
{
6161
ThemeState.SetSystemPreference(prefersDark);
62-
InvokeAsync(StateHasChanged);
62+
await ApplyTheme();
6363
}
6464

6565
private async Task Toggle()
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using Bunit;
2+
using FluentAssertions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using NuGetTrends.Web.Client.Models;
5+
using NuGetTrends.Web.Client.Services;
6+
using NuGetTrends.Web.Client.Shared;
7+
using Xunit;
8+
9+
namespace NuGetTrends.Web.Tests;
10+
11+
public class ThemeToggleTests : TestContext
12+
{
13+
private readonly ThemeState _themeState = new();
14+
15+
public ThemeToggleTests()
16+
{
17+
Services.AddSingleton(_themeState);
18+
JSInterop.Mode = JSRuntimeMode.Loose;
19+
}
20+
21+
[Fact]
22+
public void Render_ShowsSystemIconByDefault()
23+
{
24+
var cut = RenderComponent<ThemeToggle>();
25+
26+
cut.Find("i").ClassList.Should().Contain("fa-desktop");
27+
}
28+
29+
[Fact]
30+
public void Click_CyclesThemeAndSavesPreference()
31+
{
32+
var cut = RenderComponent<ThemeToggle>();
33+
34+
cut.Find("button").Click();
35+
36+
_themeState.Preference.Should().Be(ThemePreference.Light);
37+
JSInterop.Invocations.Should().Contain(i =>
38+
i.Identifier == "themeInterop.setPreference" && i.Arguments[0]!.ToString() == "light");
39+
}
40+
41+
[Fact]
42+
public void Click_AppliesThemeViaJsInterop()
43+
{
44+
var cut = RenderComponent<ThemeToggle>();
45+
46+
cut.Find("button").Click();
47+
48+
JSInterop.Invocations.Should().Contain(i =>
49+
i.Identifier == "themeInterop.applyTheme" && i.Arguments[0]!.ToString() == "light");
50+
}
51+
52+
[Fact]
53+
public void OnSystemPreferenceChanged_WhenSystemMode_AppliesTheme()
54+
{
55+
var cut = RenderComponent<ThemeToggle>();
56+
57+
// Simulate OS switching to dark
58+
cut.InvokeAsync(() => cut.Instance.OnSystemPreferenceChanged(prefersDark: true));
59+
60+
JSInterop.Invocations.Should().Contain(i =>
61+
i.Identifier == "themeInterop.applyTheme" && i.Arguments[0]!.ToString() == "dark");
62+
}
63+
64+
[Fact]
65+
public void OnSystemPreferenceChanged_WhenSystemMode_TogglingBackAppliesLight()
66+
{
67+
var cut = RenderComponent<ThemeToggle>();
68+
69+
// OS goes dark then back to light
70+
cut.InvokeAsync(() => cut.Instance.OnSystemPreferenceChanged(prefersDark: true));
71+
cut.InvokeAsync(() => cut.Instance.OnSystemPreferenceChanged(prefersDark: false));
72+
73+
var applyInvocations = JSInterop.Invocations
74+
.Where(i => i.Identifier == "themeInterop.applyTheme")
75+
.Select(i => i.Arguments[0]!.ToString())
76+
.ToList();
77+
78+
// Should have applied both dark and light themes, ending on light
79+
applyInvocations.Should().Contain("dark");
80+
applyInvocations.Last().Should().Be("light");
81+
}
82+
83+
[Fact]
84+
public void OnSystemPreferenceChanged_WhenManualTheme_StillAppliesManualTheme()
85+
{
86+
var cut = RenderComponent<ThemeToggle>();
87+
88+
// User manually selects Light
89+
cut.Find("button").Click(); // System -> Light
90+
_themeState.Preference.Should().Be(ThemePreference.Light);
91+
92+
// Clear invocations to isolate the system preference change
93+
var countBefore = JSInterop.Invocations
94+
.Count(i => i.Identifier == "themeInterop.applyTheme");
95+
96+
// OS switches to dark, but user chose Light
97+
cut.InvokeAsync(() => cut.Instance.OnSystemPreferenceChanged(prefersDark: true));
98+
99+
var applyAfter = JSInterop.Invocations
100+
.Where(i => i.Identifier == "themeInterop.applyTheme")
101+
.Select(i => i.Arguments[0]!.ToString())
102+
.ToList();
103+
104+
// The last applied theme should still be "light" (the manual preference)
105+
applyAfter.Last().Should().Be("light");
106+
}
107+
108+
[Fact]
109+
public void FullCycle_SystemToDarkToLightBackToSystem()
110+
{
111+
var cut = RenderComponent<ThemeToggle>();
112+
113+
// System -> Light -> Dark -> System
114+
cut.Find("button").Click();
115+
_themeState.Preference.Should().Be(ThemePreference.Light);
116+
117+
cut.Find("button").Click();
118+
_themeState.Preference.Should().Be(ThemePreference.Dark);
119+
120+
cut.Find("button").Click();
121+
_themeState.Preference.Should().Be(ThemePreference.System);
122+
}
123+
}

src/NuGetTrends.Web/wwwroot/css/app.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,34 @@ body.dark-theme .search-pattern {
260260
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 304 304' width='304' height='304'%3E%3Cpath fill='%234a9fd4' fill-opacity='0.15' d='M44.1 224a5 5 0 1 1 0 2H0v-2h44.1zm160 48a5 5 0 1 1 0 2H82v-2h122.1zm57.8-46a5 5 0 1 1 0-2H304v2h-42.1zm0 16a5 5 0 1 1 0-2H304v2h-42.1zm6.2-114a5 5 0 1 1 0 2h-86.2a5 5 0 1 1 0-2h86.2zm-256-48a5 5 0 1 1 0 2H0v-2h12.1zm185.8 34a5 5 0 1 1 0-2h86.2a5 5 0 1 1 0 2h-86.2zM258 12.1a5 5 0 1 1-2 0V0h2v12.1zm-64 208a5 5 0 1 1-2 0v-54.2a5 5 0 1 1 2 0v54.2zm48-198.2V80h62v2h-64V21.9a5 5 0 1 1 2 0zm16 16V64h46v2h-48V37.9a5 5 0 1 1 2 0zm-128 96V208h16v12.1a5 5 0 1 1-2 0V210h-16v-76.1a5 5 0 1 1 2 0zm-5.9-21.9a5 5 0 1 1 0 2H114v48H85.9a5 5 0 1 1 0-2H112v-48h12.1zm-6.2 130a5 5 0 1 1 0-2H176v-74.1a5 5 0 1 1 2 0V242h-60.1zm-16-64a5 5 0 1 1 0-2H114v48h10.1a5 5 0 1 1 0 2H112v-48h-10.1zM66 284.1a5 5 0 1 1-2 0V274H50v30h-2v-32h18v12.1zM236.1 176a5 5 0 1 1 0 2H226v94h48v32h-2v-30h-48v-98h12.1zm25.8-30a5 5 0 1 1 0-2H274v44.1a5 5 0 1 1-2 0V146h-10.1zm-64 96a5 5 0 1 1 0-2H208v-80h16v-14h-42.1a5 5 0 1 1 0-2H226v18h-16v80h-12.1zm86.2-210a5 5 0 1 1 0 2H272V0h2v32h10.1zM98 101.9V146H53.9a5 5 0 1 1 0-2H96v-42.1a5 5 0 1 1 2 0zM53.9 34a5 5 0 1 1 0-2H80V0h2v34H53.9zm60.1 3.9V66H82v64H69.9a5 5 0 1 1 0-2H80V64h32V37.9a5 5 0 1 1 2 0zM101.9 82a5 5 0 1 1 0-2H128V37.9a5 5 0 1 1 2 0V82h-28.1zm16-64a5 5 0 1 1 0-2H146v44.1a5 5 0 1 1-2 0V18h-26.1zm102.2 270a5 5 0 1 1 0 2H98v14h-2v-16h124.1zM242 149.9V160h16v34h-16v62h48v48h-2v-46h-48v-66h16v-30h-16v-12.1a5 5 0 1 1 2 0zM53.9 18a5 5 0 1 1 0-2H64V2H48V0h18v18H53.9zm112 32a5 5 0 1 1 0-2H192V0h50v2h-48v48h-28.1zm-48-48a5 5 0 0 1-9.8-2h2.07a3 3 0 1 0 5.66 0H178v34h-18V21.9a5 5 0 1 1 2 0V32h14V2h-58.1zm0 96a5 5 0 1 1 0-2H137l32-32h39V21.9a5 5 0 1 1 2 0V66h-40.17l-32 32H117.9zm28.1 90.1a5 5 0 1 1-2 0v-76.51L175.59 80H224V21.9a5 5 0 1 1 2 0V82h-49.59L146 112.41v75.69zm16 32a5 5 0 1 1-2 0v-99.51L184.59 96H300.1a5 5 0 0 1 3.9-3.9v2.07a3 3 0 0 0 0 5.66v2.07a5 5 0 0 1-3.9-3.9H185.41L162 121.41v98.69zm-144-64a5 5 0 1 1-2 0v-3.51l48-48V48h32V0h2v50H66v55.41l-48 48v2.69zM50 53.9v43.51l-48 48V208h26.1a5 5 0 1 1 0 2H0v-65.41l48-48V53.9a5 5 0 1 1 2 0zm-16 16V89.41l-34 34v-2.82l32-32V69.9a5 5 0 1 1 2 0zM12.1 32a5 5 0 1 1 0 2H9.41L0 43.41V40.6L8.59 32h3.51zm265.8 18a5 5 0 1 1 0-2h18.69l7.41-7.41v2.82L297.41 50H277.9zm-16 160a5 5 0 1 1 0-2H288v-71.41l16-16v2.82l-14 14V210h-28.1zm-208 32a5 5 0 1 1 0-2H64v-22.59L40.59 194H21.9a5 5 0 1 1 0-2H41.41L66 216.59V242H53.9zm150.2 14a5 5 0 1 1 0 2H96v-56.6L56.6 162H37.9a5 5 0 1 1 0-2h19.5L98 200.6V256h106.1zm-150.2 2a5 5 0 1 1 0-2H80v-46.59L48.59 178H21.9a5 5 0 1 1 0-2H49.41L82 208.59V258H53.9zM34 39.8v1.61L9.41 66H0v-2h8.59L32 40.59V0h2v39.8zM2 300.1a5 5 0 0 1 3.9 3.9H3.83a3 3 0 0 0-1.764-1.775V300.1zm34 0v2.07a3 3 0 0 0 0 5.66v2.07a5 5 0 0 1-3.9-3.9H32v-62h-2v60H0v2h34zm210 0a5 5 0 0 1 3.9-3.9v2.07a3 3 0 0 0 0 5.66v2.07a5 5 0 0 1-3.9-3.9H244v-60h-2v62h10v-2zm-74 58h-2v-18h2v18zM304 0v2h-32v-2h32zm-64 0v2h-32v-2h32zm-96 0v2h-32v-2h32zm-64 0v2H48v-2h32zM40 262l-16 16v2l18-18 40 40 42-42 60 60 12-12 24 24v-2l-22-22 32-32-10-10h2l12 12-36 36 22 22v2l-26-26-10 10-62-62-40 40-20-20 16-16v-2l-18 18 22 22v2l-24-24z'%3E%3C/path%3E%3C/svg%3E");
261261
}
262262

263+
/* Sentry Feedback Widget theme overrides.
264+
* The widget uses colorScheme: "system" with @media (prefers-color-scheme) inside its Shadow DOM.
265+
* CSS custom properties set on the host from outside override :host rules inside,
266+
* so the widget follows the app's theme toggle rather than the OS preference. */
267+
body.light-theme #sentry-feedback {
268+
--foreground: #2b2233;
269+
--background: #ffffff;
270+
--accent-foreground: white;
271+
--accent-background: #215C84;
272+
--success-color: #268d75;
273+
--error-color: #df3338;
274+
--border: 1.5px solid rgba(41, 35, 47, 0.13);
275+
--box-shadow: 0px 4px 24px 0px rgba(43, 34, 51, 0.12);
276+
--interactive-filter: brightness(95%);
277+
}
278+
279+
body.dark-theme #sentry-feedback {
280+
--foreground: #ebe6ef;
281+
--background: #29232f;
282+
--accent-foreground: white;
283+
--accent-background: #4a9fd4;
284+
--success-color: #2da98c;
285+
--error-color: #f55459;
286+
--border: 1.5px solid rgba(235, 230, 239, 0.15);
287+
--box-shadow: 0px 4px 24px 0px rgba(43, 34, 51, 0.12);
288+
--interactive-filter: brightness(150%);
289+
}
290+
263291
/* Input theming */
264292
.input,
265293
.textarea {

0 commit comments

Comments
 (0)