maui-theming: never Clear() MergedDictionaries when swapping a theme

The gate had maui-theming at 3W/1T/0L, +30% [-1.8%, +61.8%] - one tie away
from passing, on "Avoid common theming mistakes". Comparing the trajectories,
baseline and skilled were near-identical and both satisfied all four rubric
items, so there was nothing to win on... except that BOTH emitted the same
real bug:

    var merged = Application.Current.Resources.MergedDictionaries;
    merged.Clear();
    merged.Add(new DarkTheme());

The default MAUI template merges Resources/Styles/Colors.xaml and Styles.xaml
into Application.Resources. Clear() removes those too, so every implicit style,
brush and colour in the app silently disappears.

Verified at runtime in a net11.0-maccatalyst app built from the default
template:

  before Clear(): merged=2  'Primary' resolves=True   value=[Color Red=0.317...]
  after  Clear(): merged=1  'Primary' resolves=False  value=<GONE>

The skill previously had a weak comment acknowledging this ("assumes theme
dictionaries are the only merged dictionaries... move them to
Application.Resources directly instead"), which is both easy to miss and poor
advice. Replaced across SKILL.md (both occurrences) and theming-api.md with a
tracked-reference swap that removes only the previous theme:

    static ResourceDictionary? _currentTheme;
    if (_currentTheme is not null) merged.Remove(_currentTheme);
    merged.Add(theme);
    _currentTheme = theme;

Also verified at runtime: app styles survive across two consecutive theme
swaps and the dictionary count stays flat (2 -> 3 -> 3, 'Primary' OK
throughout), so it neither leaks nor unstyles the app.

Measured (n=4/scenario, 16 trials, executor claude-opus-4.6, judge gpt-5.5):

  before   14W/1T/1L   +0.33 [+0.21, +0.44]   88% win
  after    15W/1T/0L   +0.38 [+0.32, +0.43]   94% win

"Avoid common theming mistakes" - the scenario that tied on the gate - now
wins 4/4, and the remaining single tie is on a different scenario. Tightest
confidence interval of any arm measured in this PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 58513e0c-4086-4112-9ea1-95dbd8e8e469
This commit is contained in:
Jakub Florkowski
2026-07-29 00:34:20 +02:00
parent 4d61dd9e29
commit 714b5975ea
2 changed files with 49 additions and 13 deletions
@@ -205,21 +205,41 @@ Use `DynamicResource` so values update when the dictionary is swapped at runtime
### Step 3 — Switch Themes at Runtime
> 🚨 **Never call `MergedDictionaries.Clear()` to swap a theme.** The default MAUI
> template merges `Resources/Styles/Colors.xaml` and `Styles.xaml` into
> `Application.Resources`. `Clear()` removes **those too**, so every implicit style,
> brush and colour in the app silently disappears — buttons, entries and labels all
> revert to unstyled defaults. Verified: after `Clear()`, `MergedDictionaries` drops
> from 2 to 1 and the template's `Primary` colour no longer resolves.
Remove only the theme you added, and leave everything else alone:
```csharp
static ResourceDictionary? _currentTheme;
void ApplyTheme(ResourceDictionary theme)
{
// Assumes theme dictionaries are the only merged dictionaries.
// If your App.xaml merges non-theme dictionaries (e.g., converters),
// move them to Application.Resources directly instead.
var mergedDictionaries = Application.Current!.Resources.MergedDictionaries;
mergedDictionaries.Clear();
mergedDictionaries.Add(theme);
var merged = Application.Current!.Resources.MergedDictionaries;
// ✅ Remove ONLY the previous theme — Colors.xaml / Styles.xaml survive
if (_currentTheme is not null)
merged.Remove(_currentTheme);
merged.Add(theme);
_currentTheme = theme;
}
// Usage
ApplyTheme(new DarkTheme());
```
```csharp
// ❌ Destroys the app's Colors.xaml and Styles.xaml along with the old theme
var merged = Application.Current!.Resources.MergedDictionaries;
merged.Clear();
merged.Add(theme);
```
## System Theme Detection
### Read the Current Theme
@@ -327,11 +347,19 @@ diagnose this, always show the swap and the system-theme hook alongside the fix
otherwise the user has a corrected binding that still never updates:
```csharp
static ResourceDictionary? _currentTheme;
void ApplyTheme(bool useDark)
{
var merged = Application.Current!.Resources.MergedDictionaries;
merged.Clear();
merged.Add(useDark ? new DarkTheme() : new LightTheme());
// Remove only the previous theme — never Clear(), which also wipes
// the template's Colors.xaml / Styles.xaml
if (_currentTheme is not null)
merged.Remove(_currentTheme);
_currentTheme = useDark ? new DarkTheme() : new LightTheme();
merged.Add(_currentTheme);
}
// React to the OS switching light/dark
@@ -375,6 +403,6 @@ Every `x:Key` used in one theme dictionary must exist in all other theme diction
- **Read OS theme** → `Application.Current.RequestedTheme`
- **Force theme** → `Application.Current.UserAppTheme = AppTheme.Dark`
- **Theme changes** → `RequestedThemeChanged` event
- **Custom switching** → Swap `ResourceDictionary` in `MergedDictionaries`
- **Custom switching** → `Remove` the old theme from `MergedDictionaries`, then `Add` the new one — **never `Clear()`**
- **Runtime bindings** → **`DynamicResource`** (not `StaticResource`)
- **Persist choice** → `Preferences.Set` / `Preferences.Get`
@@ -79,13 +79,21 @@ Use `DynamicResource` (not `StaticResource`) so values update at runtime when th
### Switch Themes at Runtime
Remove only the previous theme. `MergedDictionaries.Clear()` also removes the
template's `Colors.xaml` / `Styles.xaml`, silently unstyling the whole app.
```csharp
static ResourceDictionary? _currentTheme;
void ApplyTheme(ResourceDictionary theme)
{
// Assumes theme dictionaries are the only merged dictionaries.
var mergedDictionaries = Application.Current!.Resources.MergedDictionaries;
mergedDictionaries.Clear();
mergedDictionaries.Add(theme);
var merged = Application.Current!.Resources.MergedDictionaries;
if (_currentTheme is not null)
merged.Remove(_currentTheme);
merged.Add(theme);
_currentTheme = theme;
}
// Usage