diff --git a/Avalonia.slnx b/Avalonia.slnx
index b702b0464d..b86ca527e1 100644
--- a/Avalonia.slnx
+++ b/Avalonia.slnx
@@ -147,6 +147,7 @@
+
diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs
index ad9917f84c..e09204485f 100644
--- a/nukebuild/Build.cs
+++ b/nukebuild/Build.cs
@@ -279,6 +279,7 @@ partial class Build : NukeBuild
RunCoreTest("Avalonia.Headless.NUnit.PerTest.UnitTests");
RunCoreTest("Avalonia.Headless.XUnit.PerAssembly.UnitTests");
RunCoreTest("Avalonia.Headless.XUnit.PerTest.UnitTests");
+ RunCoreTest("Avalonia.Themes.Fluent2.UnitTests");
});
Target RunRenderTests => _ => _
diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml
index 6ab30cc180..84fbadf525 100644
--- a/samples/ControlCatalog/App.xaml
+++ b/samples/ControlCatalog/App.xaml
@@ -30,6 +30,7 @@
+
diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs
index 71e4113a3a..82dc4faa42 100644
--- a/samples/ControlCatalog/App.xaml.cs
+++ b/samples/ControlCatalog/App.xaml.cs
@@ -16,6 +16,7 @@ namespace ControlCatalog
{
private readonly Styles _themeStylesContainer = new();
private FluentTheme? _fluentTheme;
+ private Avalonia.Themes.Fluent2.Fluent2Theme? _fluent2Theme;
private SimpleTheme? _simpleTheme;
private IStyle? _colorPickerFluent, _colorPickerSimple;
@@ -31,6 +32,7 @@ namespace ControlCatalog
AvaloniaXamlLoader.Load(this);
_fluentTheme = (FluentTheme)Resources["FluentTheme"]!;
+ _fluent2Theme = (Avalonia.Themes.Fluent2.Fluent2Theme)Resources["Fluent2Theme"]!;
_simpleTheme = (SimpleTheme)Resources["SimpleTheme"]!;
_colorPickerFluent = (IStyle)Resources["ColorPickerFluent"]!;
_colorPickerSimple = (IStyle)Resources["ColorPickerSimple"]!;
@@ -125,6 +127,11 @@ namespace ControlCatalog
app._themeStylesContainer[0] = app._fluentTheme!;
app._themeStylesContainer[1] = app._colorPickerFluent!;
}
+ else if (theme == CatalogTheme.Fluent2)
+ {
+ app._themeStylesContainer[0] = app._fluent2Theme!;
+ app._themeStylesContainer[1] = app._colorPickerFluent!;
+ }
else if (theme == CatalogTheme.Simple)
{
app._themeStylesContainer[0] = app._simpleTheme!;
diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index 800793ca0b..c8dc7fc5d6 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -36,6 +36,7 @@
+
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index f7b2658b0f..94f6376896 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -124,6 +124,7 @@
ToolTip.Tip="Catalog Theme">
Fluent
+ Fluent2
Simple
diff --git a/samples/ControlCatalog/Models/CatalogTheme.cs b/samples/ControlCatalog/Models/CatalogTheme.cs
index 79b3182d20..7cd67c53f8 100644
--- a/samples/ControlCatalog/Models/CatalogTheme.cs
+++ b/samples/ControlCatalog/Models/CatalogTheme.cs
@@ -3,6 +3,7 @@
public enum CatalogTheme
{
Fluent,
+ Fluent2,
Simple
}
}
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj b/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj
new file mode 100644
index 0000000000..94c8d120c3
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj
@@ -0,0 +1,21 @@
+
+
+ $(AvsCurrentTargetFramework)
+ Exe
+ true
+ $(DefineConstants);XUNIT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs
new file mode 100644
index 0000000000..6279522eb3
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Styling;
+using Avalonia.Threading;
+
+namespace Avalonia.Themes.Fluent2.UnitTests;
+
+///
+/// Constructs every control that has an implicit Fluent2 theme inside a headless
+/// window, runs layout and renders a frame. Catches template binding errors and
+/// dangling resource references that key parity alone cannot see.
+///
+public class InstantiationSmokeTests
+{
+ [AvaloniaFact]
+ public void Every_themed_control_renders_under_fluent2()
+ {
+ var v1 = new Avalonia.Themes.Fluent.FluentTheme();
+ var types = ThemeResourceWalker.CollectKeys(v1).OfType()
+ .Where(t => typeof(Control).IsAssignableFrom(t)
+ && !typeof(TopLevel).IsAssignableFrom(t)
+ && !t.IsAbstract
+ && t.IsPublic
+ && t.GetConstructor(Type.EmptyTypes) is not null)
+ .OrderBy(t => t.Name)
+ .ToList();
+
+ Assert.True(types.Count > 60, $"Only {types.Count} instantiable control types found.");
+
+ var theme = new Fluent2Theme();
+ var app = Application.Current!;
+ app.Styles.Add(theme);
+ var problems = new List();
+ try
+ {
+ foreach (var variant in new[] { ThemeVariant.Light, ThemeVariant.Dark })
+ {
+ var window = new Window
+ {
+ Width = 400,
+ Height = 300,
+ RequestedThemeVariant = variant,
+ };
+ window.Show();
+ try
+ {
+ foreach (var type in types)
+ {
+ try
+ {
+ var control = (Control)Activator.CreateInstance(type)!;
+ window.Content = control;
+ Dispatcher.UIThread.RunJobs();
+ window.CaptureRenderedFrame()?.Dispose();
+ }
+ catch (Exception e)
+ {
+ problems.Add($"{type.Name} ({variant}): {e.GetType().Name}: {e.Message}");
+ }
+ }
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+ }
+ finally
+ {
+ app.Styles.Remove(theme);
+ }
+
+ Assert.True(problems.Count == 0,
+ $"{problems.Count} controls failed to render:\n" + string.Join("\n", problems));
+ }
+}
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs
new file mode 100644
index 0000000000..4b3576d121
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace Avalonia.Themes.Fluent2.UnitTests;
+
+///
+/// The drop-in compatibility contract: every resource key resolvable from
+/// Avalonia.Themes.Fluent (v1) must resolve from Fluent2Theme, in both theme
+/// variants, with a compatible runtime type. Values are allowed to differ —
+/// they are the redesign.
+///
+public class KeyParityTests
+{
+ private static readonly ThemeVariant[] s_variants = { ThemeVariant.Light, ThemeVariant.Dark };
+
+ [AvaloniaFact]
+ public void Every_v1_resource_key_resolves_in_fluent2_with_compatible_type()
+ {
+ var v1 = new Avalonia.Themes.Fluent.FluentTheme();
+ var v2 = new Fluent2Theme();
+ var keys = ThemeResourceWalker.CollectKeys(v1);
+
+ // Guard against a silent enumeration bug: v1 has well over a thousand keys.
+ Assert.True(keys.Count > 1000, $"Only enumerated {keys.Count} v1 keys; walker is broken.");
+
+ var problems = new List();
+
+ foreach (var key in keys)
+ {
+ foreach (var variant in s_variants)
+ {
+ object? expected = null;
+ try
+ {
+ if (!((IResourceNode)v1).TryGetResource(key, variant, out expected))
+ continue; // not resolvable in v1 for this variant — nothing to guarantee
+ }
+ catch (Exception e)
+ {
+ problems.Add($"[v1!] {key} ({variant}): {e.Message}");
+ continue;
+ }
+
+ object? actual = null;
+ try
+ {
+ if (!((IResourceNode)v2).TryGetResource(key, variant, out actual))
+ {
+ problems.Add($"[missing] {key} ({variant})");
+ continue;
+ }
+ }
+ catch (Exception e)
+ {
+ problems.Add($"[throws] {key} ({variant}): {e.Message}");
+ continue;
+ }
+
+ var expectedCategory = Category(expected);
+ var actualCategory = Category(actual);
+ if (expectedCategory != actualCategory)
+ problems.Add($"[type] {key} ({variant}): v1={expectedCategory} v2={actualCategory}");
+ }
+ }
+
+ Assert.True(problems.Count == 0,
+ $"{problems.Count} compatibility problems:\n" + string.Join("\n", problems.Take(50)));
+ }
+
+ [AvaloniaFact]
+ public void Every_v1_implicit_control_theme_exists_in_fluent2()
+ {
+ var v1 = new Avalonia.Themes.Fluent.FluentTheme();
+ var v2 = new Fluent2Theme();
+ var types = ThemeResourceWalker.CollectKeys(v1).OfType().ToList();
+
+ Assert.True(types.Count > 70, $"Only enumerated {types.Count} implicit theme types; walker is broken.");
+
+ var problems = new List();
+ foreach (var type in types)
+ {
+ if (!((IResourceNode)v2).TryGetResource(type, ThemeVariant.Light, out var value))
+ {
+ problems.Add($"[missing] {type.Name}");
+ }
+ else if (value is not ControlTheme theme)
+ {
+ problems.Add($"[not-a-theme] {type.Name}: {value?.GetType().Name}");
+ }
+ else if (theme.TargetType != type)
+ {
+ problems.Add($"[target-type] {type.Name}: theme targets {theme.TargetType?.Name}");
+ }
+ }
+
+ Assert.True(problems.Count == 0, string.Join("\n", problems));
+ }
+
+ [AvaloniaFact]
+ public void Compact_density_dictionary_keeps_v1_key_set()
+ {
+ var v1Compact = GetCompactStyles(new Avalonia.Themes.Fluent.FluentTheme());
+ var v2Compact = GetCompactStyles(new Fluent2Theme());
+
+ var missing = v1Compact.Keys.Where(k => !v2Compact.ContainsKey(k)).ToList();
+ Assert.True(missing.Count == 0,
+ "Compact density keys missing in Fluent2: " + string.Join(", ", missing));
+ }
+
+ private static ResourceDictionary GetCompactStyles(object theme)
+ {
+ // Both themes stash the keyed CompactStyles dictionary in a private field;
+ // there is no public way to enumerate it, and compiled XAML for plain
+ // resource dictionaries cannot be loaded by URI at runtime.
+ var field = theme.GetType().GetField("_compactStyles",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ Assert.NotNull(field);
+ return Assert.IsType(field!.GetValue(theme));
+ }
+
+ private static string Category(object? value) => value switch
+ {
+ null => "null",
+ IBrush => "brush",
+ Color => "color",
+ Thickness => "thickness",
+ CornerRadius => "cornerradius",
+ double => "double",
+ bool => "bool",
+ FontFamily => "fontfamily",
+ ControlTheme ct => $"controltheme({ct.TargetType?.FullName})",
+ Geometry => "geometry",
+ ITransform => "transform",
+ TimeSpan => "timespan",
+ string => "string",
+ int => "int",
+ _ => value.GetType().FullName!,
+ };
+}
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs
new file mode 100644
index 0000000000..246ef19af3
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs
@@ -0,0 +1,87 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace Avalonia.Themes.Fluent2.UnitTests;
+
+///
+/// Fluent2Theme.Palettes must keep v1 semantics: a user-supplied Accent flows
+/// through SystemAccentColor and its shades into the WinUI accent token brushes
+/// and the per-control accent keys.
+///
+public class PaletteTests
+{
+ [AvaloniaFact]
+ public void Palette_accent_flows_into_accent_tokens_and_button_keys()
+ {
+ var theme = new Fluent2Theme();
+ theme.Palettes[ThemeVariant.Dark] = new ColorPaletteResources { Accent = Colors.Crimson };
+
+ var app = Application.Current!;
+ app.Styles.Add(theme);
+ try
+ {
+ Assert.True(app.TryGetResource("SystemAccentColor", ThemeVariant.Dark, out var accent));
+ Assert.Equal(Colors.Crimson, Assert.IsType(accent));
+
+ // The dark-variant default accent fill is SystemAccentColorLight2 —
+ // whatever shade the palette computed, the brush must match it.
+ Assert.True(app.TryGetResource("SystemAccentColorLight2", ThemeVariant.Dark, out var light2));
+ var light2Color = Assert.IsType(light2);
+ Assert.NotEqual(Colors.Crimson, light2Color);
+
+ Assert.True(app.TryGetResource("AccentFillColorDefaultBrush", ThemeVariant.Dark, out var fill));
+ Assert.Equal(light2Color, Assert.IsAssignableFrom(fill).Color);
+
+ Assert.True(app.TryGetResource("AccentButtonBackground", ThemeVariant.Dark, out var buttonFill));
+ Assert.Equal(light2Color, Assert.IsAssignableFrom(buttonFill).Color);
+ }
+ finally
+ {
+ app.Styles.Remove(theme);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Default_accent_uses_winui_static_shades()
+ {
+ var theme = new Fluent2Theme();
+ var app = Application.Current!;
+ app.Styles.Add(theme);
+ try
+ {
+ Assert.True(app.TryGetResource("SystemAccentColor", ThemeVariant.Light, out var accent));
+ Assert.Equal(Color.FromRgb(0, 120, 215), Assert.IsType(accent));
+
+ // WinUI's static shade values (SystemResources.xaml), not HSL-computed ones.
+ Assert.True(app.TryGetResource("SystemAccentColorLight2", ThemeVariant.Light, out var light2));
+ Assert.Equal(Color.Parse("#FF76B9ED"), Assert.IsType(light2));
+ Assert.True(app.TryGetResource("SystemAccentColorDark1", ThemeVariant.Light, out var dark1));
+ Assert.Equal(Color.Parse("#FF005A9E"), Assert.IsType(dark1));
+ }
+ finally
+ {
+ app.Styles.Remove(theme);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Compact_density_switches_metric_resources()
+ {
+ var theme = new Fluent2Theme();
+ var app = Application.Current!;
+ app.Styles.Add(theme);
+ try
+ {
+ Assert.True(app.TryGetResource("TextControlThemeMinHeight", ThemeVariant.Light, out var normal));
+ theme.DensityStyle = DensityStyle.Compact;
+ Assert.True(app.TryGetResource("TextControlThemeMinHeight", ThemeVariant.Light, out var compact));
+ Assert.True((double)compact! < (double)normal!,
+ $"Compact min height {compact} should be smaller than normal {normal}");
+ }
+ finally
+ {
+ app.Styles.Remove(theme);
+ }
+ }
+}
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs
new file mode 100644
index 0000000000..cc463b91c1
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs
@@ -0,0 +1,20 @@
+global using Xunit;
+global using Avalonia.Headless.XUnit;
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Themes.Fluent2.UnitTests;
+
+[assembly: AvaloniaTestApplication(typeof(TestApplication))]
+
+namespace Avalonia.Themes.Fluent2.UnitTests;
+
+public class TestApplication : Application
+{
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UseHarfBuzz()
+ .UseSkia()
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions
+ {
+ UseHeadlessDrawing = false
+ });
+}
diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs
new file mode 100644
index 0000000000..e7b0f6098d
--- /dev/null
+++ b/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Styling;
+
+namespace Avalonia.Themes.Fluent2.UnitTests;
+
+///
+/// Walks a theme's style/resource tree collecting every resource key, so the
+/// live Avalonia.Themes.Fluent (v1) instance can serve as the compatibility
+/// baseline for Fluent2Theme. C#-provided keys (accent colors) are not
+/// discoverable by walking and are listed explicitly.
+///
+internal static class ThemeResourceWalker
+{
+ public static readonly string[] AccentKeys =
+ {
+ "SystemAccentColor",
+ "SystemAccentColorDark1", "SystemAccentColorDark2", "SystemAccentColorDark3",
+ "SystemAccentColorLight1", "SystemAccentColorLight2", "SystemAccentColorLight3",
+ };
+
+ public static HashSet