diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeBoxShadows.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeBoxShadows.cs
index 2588c33120..6dbd8db3e4 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeBoxShadows.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeBoxShadows.cs
@@ -3,6 +3,14 @@
public class BitThemeBoxShadows
{
public string? Callout { get; set; }
+ public string? Callout2 { get; set; }
+ public string? Sm { get; set; }
+ public string? Nm { get; set; }
+ public string? Md { get; set; }
+ public string? Lg { get; set; }
+ public string? Xl { get; set; }
+ public string? Xxl { get; set; }
+ public string? Inner { get; set; }
public string? S1 { get; set; }
public string? S2 { get; set; }
public string? S3 { get; set; }
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeColors.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeColors.cs
index 64b3b9bb80..88cf211d8c 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeColors.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeColors.cs
@@ -36,12 +36,30 @@ public class BitThemeGeneralColorVariants
public string? Primary { get; set; }
public string? PrimaryHover { get; set; }
public string? PrimaryActive { get; set; }
+ public string? PrimaryDark { get; set; }
+ public string? PrimaryDarkHover { get; set; }
+ public string? PrimaryDarkActive { get; set; }
+ public string? PrimaryLight { get; set; }
+ public string? PrimaryLightHover { get; set; }
+ public string? PrimaryLightActive { get; set; }
public string? Secondary { get; set; }
public string? SecondaryHover { get; set; }
public string? SecondaryActive { get; set; }
+ public string? SecondaryDark { get; set; }
+ public string? SecondaryDarkHover { get; set; }
+ public string? SecondaryDarkActive { get; set; }
+ public string? SecondaryLight { get; set; }
+ public string? SecondaryLightHover { get; set; }
+ public string? SecondaryLightActive { get; set; }
public string? Tertiary { get; set; }
public string? TertiaryHover { get; set; }
public string? TertiaryActive { get; set; }
+ public string? TertiaryDark { get; set; }
+ public string? TertiaryDarkHover { get; set; }
+ public string? TertiaryDarkActive { get; set; }
+ public string? TertiaryLight { get; set; }
+ public string? TertiaryLightHover { get; set; }
+ public string? TertiaryLightActive { get; set; }
public string? Disabled { get; set; }
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeColorDerivation.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeColorDerivation.cs
new file mode 100644
index 0000000000..b2c8556417
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeColorDerivation.cs
@@ -0,0 +1,49 @@
+namespace Bit.BlazorUI;
+
+///
+/// Optional helpers to populate semantic color steps from a single main color (HSV-based). Explicit non-null values on are never overwritten.
+///
+public static class BitThemeColorDerivation
+{
+ /// Fills unset fields from .
+ public static void FillColorRoleFromMain(BitThemeColorVariants variants, string mainHex)
+ {
+ if (variants is null || string.IsNullOrWhiteSpace(mainHex)) return;
+
+ try
+ {
+ var baseColor = new BitInternalColor(mainHex.Trim());
+ var (h, s, v) = baseColor.Hsv;
+
+ variants.Main ??= baseColor.Hex;
+ variants.MainHover ??= ToHex(h, s, ScaleV(v, 0.96));
+ variants.MainActive ??= ToHex(h, s, ScaleV(v, 0.90));
+ variants.Dark ??= ToHex(h, s, ScaleV(v, 0.82));
+ variants.DarkHover ??= ToHex(h, s, ScaleV(v, 0.76));
+ variants.DarkActive ??= ToHex(h, s, ScaleV(v, 0.70));
+ variants.Light ??= ToHex(h, s, AddV(v, 0.08));
+ variants.LightHover ??= ToHex(h, s, AddV(v, 0.12));
+ variants.LightActive ??= ToHex(h, s, AddV(v, 0.16));
+ variants.Text ??= SuggestOnColorText(baseColor);
+ }
+ catch
+ {
+ // ignore invalid color strings
+ }
+ }
+
+ private static string ToHex(double h, double s, double v, double a = 1)
+ => new BitInternalColor(h, s, Clamp01(v), a).Hex!;
+
+ private static double ScaleV(double v, double factor) => Clamp01(v * factor);
+
+ private static double AddV(double v, double delta) => Clamp01(v + delta);
+
+ private static double Clamp01(double v) => v < 0 ? 0 : v > 1 ? 1 : v;
+
+ private static string SuggestOnColorText(BitInternalColor c)
+ {
+ var lum = (0.299 * c.R + 0.587 * c.G + 0.114 * c.B) / 255.0;
+ return lum > 0.55 ? "#000000" : "#FFFFFF";
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeJsExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeJsExtensions.cs
index a2267a9ed2..9078410418 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeJsExtensions.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeJsExtensions.cs
@@ -17,7 +17,7 @@ internal static ValueTask BitThemeToggleThemeDarkLight(this IJSRuntime j
return js.Invoke("BitTheme.toggleDarkLight");
}
- internal static ValueTask BitThemeApplyBitTheme(this IJSRuntime js, Dictionary theme, ElementReference? element)
+ internal static ValueTask BitThemeApplyBitTheme(this IJSRuntime js, IReadOnlyDictionary theme, ElementReference? element)
{
return js.InvokeVoid("BitTheme.applyBitTheme", theme, element);
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeManager.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeManager.cs
index e89a14cb79..5942448a9e 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeManager.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeManager.cs
@@ -1,5 +1,11 @@
namespace Bit.BlazorUI;
+///
+/// Bridges Blazor to the client BitTheme script: preset names on the bit-theme attribute vs inline CSS variables from .
+/// selects packaged Fluent CSS for :root[bit-theme].
+/// sets --bit-* variables on the target element (default document.body), overriding stylesheet defaults for that subtree.
+/// Nested scopes overrides to its root element.
+///
public class BitThemeManager
{
private IJSRuntime _js = default!;
@@ -9,26 +15,30 @@ public BitThemeManager(IJSRuntime js)
_js = js;
}
+ /// Returns the active bit-theme name from the document element.
public async ValueTask GetCurrentThemeAsync()
{
return await _js.BitThemeGetCurrentTheme();
}
+ /// Sets the bit-theme attribute (use values from or custom names matching your CSS).
public async ValueTask SetThemeAsync(string themeName)
{
return await _js.BitThemeSetTheme(themeName);
}
+ /// Toggles between configured light and dark theme names.
public async ValueTask ToggleDarkLightAsync()
{
return await _js.BitThemeToggleThemeDarkLight();
}
+ /// Applies as CSS custom properties on (default: body), overriding stylesheet tokens for that subtree.
public async ValueTask ApplyBitThemeAsync(BitTheme bitTheme, ElementReference? element = null)
{
if (bitTheme is null) return;
- await _js.BitThemeApplyBitTheme(BitThemeMapper.MapToCssVariables(bitTheme), element);
+ await _js.BitThemeApplyBitTheme(BitThemeUtilities.ToCssVariables(bitTheme), element);
}
public async ValueTask IsSystemInDarkMode()
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs
index 4abe411574..1b82bf5ece 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs
@@ -99,35 +99,89 @@ internal static Dictionary MapToCssVariables(BitTheme bitTheme)
addCssVar("--bit-clr-fg-pri", bitTheme.Color.Foreground.Primary);
addCssVar("--bit-clr-fg-pri-hover", bitTheme.Color.Foreground.PrimaryHover);
addCssVar("--bit-clr-fg-pri-active", bitTheme.Color.Foreground.PrimaryActive);
+ addCssVar("--bit-clr-fg-pri-dark", bitTheme.Color.Foreground.PrimaryDark);
+ addCssVar("--bit-clr-fg-pri-dark-hover", bitTheme.Color.Foreground.PrimaryDarkHover);
+ addCssVar("--bit-clr-fg-pri-dark-active", bitTheme.Color.Foreground.PrimaryDarkActive);
+ addCssVar("--bit-clr-fg-pri-light", bitTheme.Color.Foreground.PrimaryLight);
+ addCssVar("--bit-clr-fg-pri-light-hover", bitTheme.Color.Foreground.PrimaryLightHover);
+ addCssVar("--bit-clr-fg-pri-light-active", bitTheme.Color.Foreground.PrimaryLightActive);
addCssVar("--bit-clr-fg-sec", bitTheme.Color.Foreground.Secondary);
addCssVar("--bit-clr-fg-sec-hover", bitTheme.Color.Foreground.SecondaryHover);
addCssVar("--bit-clr-fg-sec-active", bitTheme.Color.Foreground.SecondaryActive);
+ addCssVar("--bit-clr-fg-sec-dark", bitTheme.Color.Foreground.SecondaryDark);
+ addCssVar("--bit-clr-fg-sec-dark-hover", bitTheme.Color.Foreground.SecondaryDarkHover);
+ addCssVar("--bit-clr-fg-sec-dark-active", bitTheme.Color.Foreground.SecondaryDarkActive);
+ addCssVar("--bit-clr-fg-sec-light", bitTheme.Color.Foreground.SecondaryLight);
+ addCssVar("--bit-clr-fg-sec-light-hover", bitTheme.Color.Foreground.SecondaryLightHover);
+ addCssVar("--bit-clr-fg-sec-light-active", bitTheme.Color.Foreground.SecondaryLightActive);
addCssVar("--bit-clr-fg-ter", bitTheme.Color.Foreground.Tertiary);
addCssVar("--bit-clr-fg-ter-hover", bitTheme.Color.Foreground.TertiaryHover);
addCssVar("--bit-clr-fg-ter-active", bitTheme.Color.Foreground.TertiaryActive);
+ addCssVar("--bit-clr-fg-ter-dark", bitTheme.Color.Foreground.TertiaryDark);
+ addCssVar("--bit-clr-fg-ter-dark-hover", bitTheme.Color.Foreground.TertiaryDarkHover);
+ addCssVar("--bit-clr-fg-ter-dark-active", bitTheme.Color.Foreground.TertiaryDarkActive);
+ addCssVar("--bit-clr-fg-ter-light", bitTheme.Color.Foreground.TertiaryLight);
+ addCssVar("--bit-clr-fg-ter-light-hover", bitTheme.Color.Foreground.TertiaryLightHover);
+ addCssVar("--bit-clr-fg-ter-light-active", bitTheme.Color.Foreground.TertiaryLightActive);
addCssVar("--bit-clr-fg-dis", bitTheme.Color.Foreground.Disabled);
addCssVar("--bit-clr-bg-pri", bitTheme.Color.Background.Primary);
addCssVar("--bit-clr-bg-pri-hover", bitTheme.Color.Background.PrimaryHover);
addCssVar("--bit-clr-bg-pri-active", bitTheme.Color.Background.PrimaryActive);
+ addCssVar("--bit-clr-bg-pri-dark", bitTheme.Color.Background.PrimaryDark);
+ addCssVar("--bit-clr-bg-pri-dark-hover", bitTheme.Color.Background.PrimaryDarkHover);
+ addCssVar("--bit-clr-bg-pri-dark-active", bitTheme.Color.Background.PrimaryDarkActive);
+ addCssVar("--bit-clr-bg-pri-light", bitTheme.Color.Background.PrimaryLight);
+ addCssVar("--bit-clr-bg-pri-light-hover", bitTheme.Color.Background.PrimaryLightHover);
+ addCssVar("--bit-clr-bg-pri-light-active", bitTheme.Color.Background.PrimaryLightActive);
addCssVar("--bit-clr-bg-sec", bitTheme.Color.Background.Secondary);
addCssVar("--bit-clr-bg-sec-hover", bitTheme.Color.Background.SecondaryHover);
addCssVar("--bit-clr-bg-sec-active", bitTheme.Color.Background.SecondaryActive);
+ addCssVar("--bit-clr-bg-sec-dark", bitTheme.Color.Background.SecondaryDark);
+ addCssVar("--bit-clr-bg-sec-dark-hover", bitTheme.Color.Background.SecondaryDarkHover);
+ addCssVar("--bit-clr-bg-sec-dark-active", bitTheme.Color.Background.SecondaryDarkActive);
+ addCssVar("--bit-clr-bg-sec-light", bitTheme.Color.Background.SecondaryLight);
+ addCssVar("--bit-clr-bg-sec-light-hover", bitTheme.Color.Background.SecondaryLightHover);
+ addCssVar("--bit-clr-bg-sec-light-active", bitTheme.Color.Background.SecondaryLightActive);
addCssVar("--bit-clr-bg-ter", bitTheme.Color.Background.Tertiary);
addCssVar("--bit-clr-bg-ter-hover", bitTheme.Color.Background.TertiaryHover);
addCssVar("--bit-clr-bg-ter-active", bitTheme.Color.Background.TertiaryActive);
+ addCssVar("--bit-clr-bg-ter-dark", bitTheme.Color.Background.TertiaryDark);
+ addCssVar("--bit-clr-bg-ter-dark-hover", bitTheme.Color.Background.TertiaryDarkHover);
+ addCssVar("--bit-clr-bg-ter-dark-active", bitTheme.Color.Background.TertiaryDarkActive);
+ addCssVar("--bit-clr-bg-ter-light", bitTheme.Color.Background.TertiaryLight);
+ addCssVar("--bit-clr-bg-ter-light-hover", bitTheme.Color.Background.TertiaryLightHover);
+ addCssVar("--bit-clr-bg-ter-light-active", bitTheme.Color.Background.TertiaryLightActive);
addCssVar("--bit-clr-bg-dis", bitTheme.Color.Background.Disabled);
addCssVar("--bit-clr-bg-overlay", bitTheme.Color.Background.Overlay);
addCssVar("--bit-clr-brd-pri", bitTheme.Color.Border.Primary);
addCssVar("--bit-clr-brd-pri-hover", bitTheme.Color.Border.PrimaryHover);
addCssVar("--bit-clr-brd-pri-active", bitTheme.Color.Border.PrimaryActive);
+ addCssVar("--bit-clr-brd-pri-dark", bitTheme.Color.Border.PrimaryDark);
+ addCssVar("--bit-clr-brd-pri-dark-hover", bitTheme.Color.Border.PrimaryDarkHover);
+ addCssVar("--bit-clr-brd-pri-dark-active", bitTheme.Color.Border.PrimaryDarkActive);
+ addCssVar("--bit-clr-brd-pri-light", bitTheme.Color.Border.PrimaryLight);
+ addCssVar("--bit-clr-brd-pri-light-hover", bitTheme.Color.Border.PrimaryLightHover);
+ addCssVar("--bit-clr-brd-pri-light-active", bitTheme.Color.Border.PrimaryLightActive);
addCssVar("--bit-clr-brd-sec", bitTheme.Color.Border.Secondary);
addCssVar("--bit-clr-brd-sec-hover", bitTheme.Color.Border.SecondaryHover);
addCssVar("--bit-clr-brd-sec-active", bitTheme.Color.Border.SecondaryActive);
+ addCssVar("--bit-clr-brd-sec-dark", bitTheme.Color.Border.SecondaryDark);
+ addCssVar("--bit-clr-brd-sec-dark-hover", bitTheme.Color.Border.SecondaryDarkHover);
+ addCssVar("--bit-clr-brd-sec-dark-active", bitTheme.Color.Border.SecondaryDarkActive);
+ addCssVar("--bit-clr-brd-sec-light", bitTheme.Color.Border.SecondaryLight);
+ addCssVar("--bit-clr-brd-sec-light-hover", bitTheme.Color.Border.SecondaryLightHover);
+ addCssVar("--bit-clr-brd-sec-light-active", bitTheme.Color.Border.SecondaryLightActive);
addCssVar("--bit-clr-brd-ter", bitTheme.Color.Border.Tertiary);
addCssVar("--bit-clr-brd-ter-hover", bitTheme.Color.Border.TertiaryHover);
addCssVar("--bit-clr-brd-ter-active", bitTheme.Color.Border.TertiaryActive);
+ addCssVar("--bit-clr-brd-ter-dark", bitTheme.Color.Border.TertiaryDark);
+ addCssVar("--bit-clr-brd-ter-dark-hover", bitTheme.Color.Border.TertiaryDarkHover);
+ addCssVar("--bit-clr-brd-ter-dark-active", bitTheme.Color.Border.TertiaryDarkActive);
+ addCssVar("--bit-clr-brd-ter-light", bitTheme.Color.Border.TertiaryLight);
+ addCssVar("--bit-clr-brd-ter-light-hover", bitTheme.Color.Border.TertiaryLightHover);
+ addCssVar("--bit-clr-brd-ter-light-active", bitTheme.Color.Border.TertiaryLightActive);
addCssVar("--bit-clr-brd-dis", bitTheme.Color.Border.Disabled);
addCssVar("--bit-clr-req", bitTheme.Color.Required);
@@ -158,6 +212,14 @@ internal static Dictionary MapToCssVariables(BitTheme bitTheme)
addCssVar("--bit-clr-ntr-gray220", bitTheme.Color.Neutral.Gray220);
addCssVar("--bit-shd-cal", bitTheme.BoxShadow.Callout);
+ addCssVar("--bit-shd-cal2", bitTheme.BoxShadow.Callout2);
+ addCssVar("--bit-shd-sm", bitTheme.BoxShadow.Sm);
+ addCssVar("--bit-shd-nm", bitTheme.BoxShadow.Nm);
+ addCssVar("--bit-shd-md", bitTheme.BoxShadow.Md);
+ addCssVar("--bit-shd-lg", bitTheme.BoxShadow.Lg);
+ addCssVar("--bit-shd-xl", bitTheme.BoxShadow.Xl);
+ addCssVar("--bit-shd-2xl", bitTheme.BoxShadow.Xxl);
+ addCssVar("--bit-shd-inner", bitTheme.BoxShadow.Inner);
addCssVar("--bit-shd-1", bitTheme.BoxShadow.S1);
addCssVar("--bit-shd-2", bitTheme.BoxShadow.S2);
addCssVar("--bit-shd-3", bitTheme.BoxShadow.S3);
@@ -250,12 +312,6 @@ internal static Dictionary MapToCssVariables(BitTheme bitTheme)
addCssVar("--bit-tpg-h3-line-height", bitTheme.Typography.H3.LineHeight);
addCssVar("--bit-tpg-h3-letter-spacing", bitTheme.Typography.H3.LetterSpacing);
- addCssVar("--bit-tpg-h3-margin", bitTheme.Typography.H3.Margin);
- addCssVar("--bit-tpg-h3-font-weight", bitTheme.Typography.H3.FontWeight);
- addCssVar("--bit-tpg-h3-font-size", bitTheme.Typography.H3.FontSize);
- addCssVar("--bit-tpg-h3-line-height", bitTheme.Typography.H3.LineHeight);
- addCssVar("--bit-tpg-h3-letter-spacing", bitTheme.Typography.H3.LetterSpacing);
-
addCssVar("--bit-tpg-h4-margin", bitTheme.Typography.H4.Margin);
addCssVar("--bit-tpg-h4-font-weight", bitTheme.Typography.H4.FontWeight);
addCssVar("--bit-tpg-h4-font-size", bitTheme.Typography.H4.FontSize);
@@ -401,35 +457,89 @@ internal static BitTheme Merge(BitTheme bitTheme, BitTheme other)
result.Color.Foreground.Primary = bitTheme.Color.Foreground.Primary ?? other.Color.Foreground.Primary;
result.Color.Foreground.PrimaryHover = bitTheme.Color.Foreground.PrimaryHover ?? other.Color.Foreground.PrimaryHover;
result.Color.Foreground.PrimaryActive = bitTheme.Color.Foreground.PrimaryActive ?? other.Color.Foreground.PrimaryActive;
+ result.Color.Foreground.PrimaryDark = bitTheme.Color.Foreground.PrimaryDark ?? other.Color.Foreground.PrimaryDark;
+ result.Color.Foreground.PrimaryDarkHover = bitTheme.Color.Foreground.PrimaryDarkHover ?? other.Color.Foreground.PrimaryDarkHover;
+ result.Color.Foreground.PrimaryDarkActive = bitTheme.Color.Foreground.PrimaryDarkActive ?? other.Color.Foreground.PrimaryDarkActive;
+ result.Color.Foreground.PrimaryLight = bitTheme.Color.Foreground.PrimaryLight ?? other.Color.Foreground.PrimaryLight;
+ result.Color.Foreground.PrimaryLightHover = bitTheme.Color.Foreground.PrimaryLightHover ?? other.Color.Foreground.PrimaryLightHover;
+ result.Color.Foreground.PrimaryLightActive = bitTheme.Color.Foreground.PrimaryLightActive ?? other.Color.Foreground.PrimaryLightActive;
result.Color.Foreground.Secondary = bitTheme.Color.Foreground.Secondary ?? other.Color.Foreground.Secondary;
result.Color.Foreground.SecondaryHover = bitTheme.Color.Foreground.SecondaryHover ?? other.Color.Foreground.SecondaryHover;
result.Color.Foreground.SecondaryActive = bitTheme.Color.Foreground.SecondaryActive ?? other.Color.Foreground.SecondaryActive;
+ result.Color.Foreground.SecondaryDark = bitTheme.Color.Foreground.SecondaryDark ?? other.Color.Foreground.SecondaryDark;
+ result.Color.Foreground.SecondaryDarkHover = bitTheme.Color.Foreground.SecondaryDarkHover ?? other.Color.Foreground.SecondaryDarkHover;
+ result.Color.Foreground.SecondaryDarkActive = bitTheme.Color.Foreground.SecondaryDarkActive ?? other.Color.Foreground.SecondaryDarkActive;
+ result.Color.Foreground.SecondaryLight = bitTheme.Color.Foreground.SecondaryLight ?? other.Color.Foreground.SecondaryLight;
+ result.Color.Foreground.SecondaryLightHover = bitTheme.Color.Foreground.SecondaryLightHover ?? other.Color.Foreground.SecondaryLightHover;
+ result.Color.Foreground.SecondaryLightActive = bitTheme.Color.Foreground.SecondaryLightActive ?? other.Color.Foreground.SecondaryLightActive;
result.Color.Foreground.Tertiary = bitTheme.Color.Foreground.Tertiary ?? other.Color.Foreground.Tertiary;
result.Color.Foreground.TertiaryHover = bitTheme.Color.Foreground.TertiaryHover ?? other.Color.Foreground.TertiaryHover;
result.Color.Foreground.TertiaryActive = bitTheme.Color.Foreground.TertiaryActive ?? other.Color.Foreground.TertiaryActive;
+ result.Color.Foreground.TertiaryDark = bitTheme.Color.Foreground.TertiaryDark ?? other.Color.Foreground.TertiaryDark;
+ result.Color.Foreground.TertiaryDarkHover = bitTheme.Color.Foreground.TertiaryDarkHover ?? other.Color.Foreground.TertiaryDarkHover;
+ result.Color.Foreground.TertiaryDarkActive = bitTheme.Color.Foreground.TertiaryDarkActive ?? other.Color.Foreground.TertiaryDarkActive;
+ result.Color.Foreground.TertiaryLight = bitTheme.Color.Foreground.TertiaryLight ?? other.Color.Foreground.TertiaryLight;
+ result.Color.Foreground.TertiaryLightHover = bitTheme.Color.Foreground.TertiaryLightHover ?? other.Color.Foreground.TertiaryLightHover;
+ result.Color.Foreground.TertiaryLightActive = bitTheme.Color.Foreground.TertiaryLightActive ?? other.Color.Foreground.TertiaryLightActive;
result.Color.Foreground.Disabled = bitTheme.Color.Foreground.Disabled ?? other.Color.Foreground.Disabled;
result.Color.Background.Primary = bitTheme.Color.Background.Primary ?? other.Color.Background.Primary;
result.Color.Background.PrimaryHover = bitTheme.Color.Background.PrimaryHover ?? other.Color.Background.PrimaryHover;
result.Color.Background.PrimaryActive = bitTheme.Color.Background.PrimaryActive ?? other.Color.Background.PrimaryActive;
+ result.Color.Background.PrimaryDark = bitTheme.Color.Background.PrimaryDark ?? other.Color.Background.PrimaryDark;
+ result.Color.Background.PrimaryDarkHover = bitTheme.Color.Background.PrimaryDarkHover ?? other.Color.Background.PrimaryDarkHover;
+ result.Color.Background.PrimaryDarkActive = bitTheme.Color.Background.PrimaryDarkActive ?? other.Color.Background.PrimaryDarkActive;
+ result.Color.Background.PrimaryLight = bitTheme.Color.Background.PrimaryLight ?? other.Color.Background.PrimaryLight;
+ result.Color.Background.PrimaryLightHover = bitTheme.Color.Background.PrimaryLightHover ?? other.Color.Background.PrimaryLightHover;
+ result.Color.Background.PrimaryLightActive = bitTheme.Color.Background.PrimaryLightActive ?? other.Color.Background.PrimaryLightActive;
result.Color.Background.Secondary = bitTheme.Color.Background.Secondary ?? other.Color.Background.Secondary;
result.Color.Background.SecondaryHover = bitTheme.Color.Background.SecondaryHover ?? other.Color.Background.SecondaryHover;
result.Color.Background.SecondaryActive = bitTheme.Color.Background.SecondaryActive ?? other.Color.Background.SecondaryActive;
+ result.Color.Background.SecondaryDark = bitTheme.Color.Background.SecondaryDark ?? other.Color.Background.SecondaryDark;
+ result.Color.Background.SecondaryDarkHover = bitTheme.Color.Background.SecondaryDarkHover ?? other.Color.Background.SecondaryDarkHover;
+ result.Color.Background.SecondaryDarkActive = bitTheme.Color.Background.SecondaryDarkActive ?? other.Color.Background.SecondaryDarkActive;
+ result.Color.Background.SecondaryLight = bitTheme.Color.Background.SecondaryLight ?? other.Color.Background.SecondaryLight;
+ result.Color.Background.SecondaryLightHover = bitTheme.Color.Background.SecondaryLightHover ?? other.Color.Background.SecondaryLightHover;
+ result.Color.Background.SecondaryLightActive = bitTheme.Color.Background.SecondaryLightActive ?? other.Color.Background.SecondaryLightActive;
result.Color.Background.Tertiary = bitTheme.Color.Background.Tertiary ?? other.Color.Background.Tertiary;
result.Color.Background.TertiaryHover = bitTheme.Color.Background.TertiaryHover ?? other.Color.Background.TertiaryHover;
result.Color.Background.TertiaryActive = bitTheme.Color.Background.TertiaryActive ?? other.Color.Background.TertiaryActive;
+ result.Color.Background.TertiaryDark = bitTheme.Color.Background.TertiaryDark ?? other.Color.Background.TertiaryDark;
+ result.Color.Background.TertiaryDarkHover = bitTheme.Color.Background.TertiaryDarkHover ?? other.Color.Background.TertiaryDarkHover;
+ result.Color.Background.TertiaryDarkActive = bitTheme.Color.Background.TertiaryDarkActive ?? other.Color.Background.TertiaryDarkActive;
+ result.Color.Background.TertiaryLight = bitTheme.Color.Background.TertiaryLight ?? other.Color.Background.TertiaryLight;
+ result.Color.Background.TertiaryLightHover = bitTheme.Color.Background.TertiaryLightHover ?? other.Color.Background.TertiaryLightHover;
+ result.Color.Background.TertiaryLightActive = bitTheme.Color.Background.TertiaryLightActive ?? other.Color.Background.TertiaryLightActive;
result.Color.Background.Disabled = bitTheme.Color.Background.Disabled ?? other.Color.Background.Disabled;
result.Color.Background.Overlay = bitTheme.Color.Background.Overlay ?? other.Color.Background.Overlay;
result.Color.Border.Primary = bitTheme.Color.Border.Primary ?? other.Color.Border.Primary;
result.Color.Border.PrimaryHover = bitTheme.Color.Border.PrimaryHover ?? other.Color.Border.PrimaryHover;
result.Color.Border.PrimaryActive = bitTheme.Color.Border.PrimaryActive ?? other.Color.Border.PrimaryActive;
+ result.Color.Border.PrimaryDark = bitTheme.Color.Border.PrimaryDark ?? other.Color.Border.PrimaryDark;
+ result.Color.Border.PrimaryDarkHover = bitTheme.Color.Border.PrimaryDarkHover ?? other.Color.Border.PrimaryDarkHover;
+ result.Color.Border.PrimaryDarkActive = bitTheme.Color.Border.PrimaryDarkActive ?? other.Color.Border.PrimaryDarkActive;
+ result.Color.Border.PrimaryLight = bitTheme.Color.Border.PrimaryLight ?? other.Color.Border.PrimaryLight;
+ result.Color.Border.PrimaryLightHover = bitTheme.Color.Border.PrimaryLightHover ?? other.Color.Border.PrimaryLightHover;
+ result.Color.Border.PrimaryLightActive = bitTheme.Color.Border.PrimaryLightActive ?? other.Color.Border.PrimaryLightActive;
result.Color.Border.Secondary = bitTheme.Color.Border.Secondary ?? other.Color.Border.Secondary;
result.Color.Border.SecondaryHover = bitTheme.Color.Border.SecondaryHover ?? other.Color.Border.SecondaryHover;
result.Color.Border.SecondaryActive = bitTheme.Color.Border.SecondaryActive ?? other.Color.Border.SecondaryActive;
+ result.Color.Border.SecondaryDark = bitTheme.Color.Border.SecondaryDark ?? other.Color.Border.SecondaryDark;
+ result.Color.Border.SecondaryDarkHover = bitTheme.Color.Border.SecondaryDarkHover ?? other.Color.Border.SecondaryDarkHover;
+ result.Color.Border.SecondaryDarkActive = bitTheme.Color.Border.SecondaryDarkActive ?? other.Color.Border.SecondaryDarkActive;
+ result.Color.Border.SecondaryLight = bitTheme.Color.Border.SecondaryLight ?? other.Color.Border.SecondaryLight;
+ result.Color.Border.SecondaryLightHover = bitTheme.Color.Border.SecondaryLightHover ?? other.Color.Border.SecondaryLightHover;
+ result.Color.Border.SecondaryLightActive = bitTheme.Color.Border.SecondaryLightActive ?? other.Color.Border.SecondaryLightActive;
result.Color.Border.Tertiary = bitTheme.Color.Border.Tertiary ?? other.Color.Border.Tertiary;
result.Color.Border.TertiaryHover = bitTheme.Color.Border.TertiaryHover ?? other.Color.Border.TertiaryHover;
result.Color.Border.TertiaryActive = bitTheme.Color.Border.TertiaryActive ?? other.Color.Border.TertiaryActive;
+ result.Color.Border.TertiaryDark = bitTheme.Color.Border.TertiaryDark ?? other.Color.Border.TertiaryDark;
+ result.Color.Border.TertiaryDarkHover = bitTheme.Color.Border.TertiaryDarkHover ?? other.Color.Border.TertiaryDarkHover;
+ result.Color.Border.TertiaryDarkActive = bitTheme.Color.Border.TertiaryDarkActive ?? other.Color.Border.TertiaryDarkActive;
+ result.Color.Border.TertiaryLight = bitTheme.Color.Border.TertiaryLight ?? other.Color.Border.TertiaryLight;
+ result.Color.Border.TertiaryLightHover = bitTheme.Color.Border.TertiaryLightHover ?? other.Color.Border.TertiaryLightHover;
+ result.Color.Border.TertiaryLightActive = bitTheme.Color.Border.TertiaryLightActive ?? other.Color.Border.TertiaryLightActive;
result.Color.Border.Disabled = bitTheme.Color.Border.Disabled ?? other.Color.Border.Disabled;
result.Color.Required = bitTheme.Color.Required ?? other.Color.Required;
@@ -460,6 +570,14 @@ internal static BitTheme Merge(BitTheme bitTheme, BitTheme other)
result.Color.Neutral.Gray220 = bitTheme.Color.Neutral.Gray220 ?? other.Color.Neutral.Gray220;
result.BoxShadow.Callout = bitTheme.BoxShadow.Callout ?? other.BoxShadow.Callout;
+ result.BoxShadow.Callout2 = bitTheme.BoxShadow.Callout2 ?? other.BoxShadow.Callout2;
+ result.BoxShadow.Sm = bitTheme.BoxShadow.Sm ?? other.BoxShadow.Sm;
+ result.BoxShadow.Nm = bitTheme.BoxShadow.Nm ?? other.BoxShadow.Nm;
+ result.BoxShadow.Md = bitTheme.BoxShadow.Md ?? other.BoxShadow.Md;
+ result.BoxShadow.Lg = bitTheme.BoxShadow.Lg ?? other.BoxShadow.Lg;
+ result.BoxShadow.Xl = bitTheme.BoxShadow.Xl ?? other.BoxShadow.Xl;
+ result.BoxShadow.Xxl = bitTheme.BoxShadow.Xxl ?? other.BoxShadow.Xxl;
+ result.BoxShadow.Inner = bitTheme.BoxShadow.Inner ?? other.BoxShadow.Inner;
result.BoxShadow.S1 = bitTheme.BoxShadow.S1 ?? other.BoxShadow.S1;
result.BoxShadow.S2 = bitTheme.BoxShadow.S2 ?? other.BoxShadow.S2;
result.BoxShadow.S3 = bitTheme.BoxShadow.S3 ?? other.BoxShadow.S3;
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemePresets.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemePresets.cs
new file mode 100644
index 0000000000..558c105462
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemePresets.cs
@@ -0,0 +1,15 @@
+namespace Bit.BlazorUI;
+
+///
+/// Well-known values for the bit-theme HTML attribute and for .
+/// Fluent presets load colors from the packaged Fluent stylesheets; overrides apply on top via inline CSS variables.
+///
+public static class BitThemePresets
+{
+ public const string Light = "light";
+ public const string Dark = "dark";
+ public const string Fluent = "fluent";
+ public const string FluentLight = "fluent-light";
+ public const string FluentDark = "fluent-dark";
+ public const string System = "system";
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeProvider.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeProvider.cs
index c7a2dc5c09..9ccecda73e 100644
--- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeProvider.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeProvider.cs
@@ -18,7 +18,8 @@ public partial class BitThemeProvider : ComponentBase
[Parameter] public BitTheme? Theme { get; set; }
///
- /// The name of the cascading BitTheme value.
+ /// Optional name for ; when set, consumers use [CascadingParameter(Name = …)].
+ /// The cascaded is the merge of with (same as inline CSS variables on this provider’s root).
///
[Parameter] public string? ThemeName { get; set; }
@@ -44,7 +45,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
mergedTheme = BitThemeMapper.Merge(Theme, ParentTheme);
}
- var cssVars = BitThemeMapper.MapToCssVariables(Theme);
+ var cssVars = BitThemeMapper.MapToCssVariables(mergedTheme);
var cssVarStyle = string.Join(';', cssVars.Select(kv => $"{kv.Key}:{kv.Value}"));
builder.OpenElement(seq++, RootElement ?? "div");
@@ -54,7 +55,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
if (ThemeName is not null)
{
builder.AddAttribute(seq++, "Name", ThemeName);
- builder.AddAttribute(seq++, "Value", Theme);
+ builder.AddAttribute(seq++, "Value", mergedTheme);
}
else
{
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSerialization.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSerialization.cs
new file mode 100644
index 0000000000..f81353808d
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSerialization.cs
@@ -0,0 +1,49 @@
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+
+namespace Bit.BlazorUI;
+
+///
+/// Serialize and deserialize for storage, admin UIs, or sharing brand tokens.
+///
+public static class BitThemeSerialization
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public static string Serialize(BitTheme theme)
+ {
+ var node = JsonSerializer.SerializeToNode(theme ?? new BitTheme(), Options);
+ if (node is JsonObject root)
+ PruneEmptyObjects(root);
+ return node?.ToJsonString(Options) ?? "{}";
+ }
+
+ public static BitTheme Deserialize(string json)
+ {
+ return string.IsNullOrWhiteSpace(json)
+ ? new BitTheme()
+ : (JsonSerializer.Deserialize(json, Options) ?? new BitTheme());
+ }
+
+ // Recursively removes nested objects that have no token values set (all-null properties).
+ // Works bottom-up: prune children first, then remove the child key if it became empty.
+ private static void PruneEmptyObjects(JsonObject obj)
+ {
+ foreach (var key in obj.Select(p => p.Key).ToList())
+ {
+ if (obj[key] is JsonObject child)
+ {
+ PruneEmptyObjects(child);
+ if (child.Count == 0)
+ obj.Remove(key);
+ }
+ }
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSsr.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSsr.cs
new file mode 100644
index 0000000000..a6a4499208
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeSsr.cs
@@ -0,0 +1,22 @@
+namespace Bit.BlazorUI;
+
+///
+/// Optional first-paint theme bootstrap for apps that use bit-theme-persist on the document element.
+/// Emit at the start of <head> (before stylesheets) so the correct bit-theme attribute is set before first paint.
+///
+public static class BitThemeSsr
+{
+ ///
+ /// Inline script only (no script tag). Wrap in a script element in your host page or layout.
+ ///
+ public const string InlineHeadScriptBody =
+ "(function(){var r=document.documentElement,k='bit-current-theme',cur;" +
+ "if(r.hasAttribute('bit-theme-persist')){cur=localStorage.getItem(k);}" +
+ "cur=cur||r.getAttribute('bit-theme')||r.getAttribute('bit-theme-default')||'light';" +
+ "var lt=r.getAttribute('bit-theme-light')||'light',dk=r.getAttribute('bit-theme-dark')||'dark';" +
+ "if(cur==='system'){cur=(window.matchMedia&&matchMedia('(prefers-color-scheme:dark)').matches)?dk:lt;}" +
+ "r.setAttribute('bit-theme',cur);})();";
+
+ /// Full script element markup for convenience.
+ public static string InlineHeadScript => $"";
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeUtilities.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeUtilities.cs
new file mode 100644
index 0000000000..2b4915d2c4
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeUtilities.cs
@@ -0,0 +1,19 @@
+namespace Bit.BlazorUI;
+
+///
+/// Public helpers around and the internal CSS-variable mapper.
+///
+public static class BitThemeUtilities
+{
+ /// Maps a theme to CSS custom property names and values for use with or inline styles.
+ public static IReadOnlyDictionary ToCssVariables(BitTheme bitTheme)
+ {
+ return BitThemeMapper.MapToCssVariables(bitTheme ?? new BitTheme());
+ }
+
+ /// Merges two themes: wins; missing values fall back to .
+ public static BitTheme Merge(BitTheme overrides, BitTheme baseline)
+ {
+ return BitThemeMapper.Merge(overrides ?? new BitTheme(), baseline ?? new BitTheme());
+ }
+}
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/ThemingPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/ThemingPage.razor
index 99b5a9fff3..b978899f42 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/ThemingPage.razor
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/ThemingPage.razor
@@ -222,6 +222,80 @@ _bitThemeManager.ApplyBitThemeAsync(myTheme);
var currentTheme = await _bitThemeManager.GetCurrentThemeAsync();
+
+
+
+ Precedence
+
+ SetThemeAsync updates the bit-theme attribute on the document root so the packaged Fluent stylesheets supply the base token values.
+ ApplyBitThemeAsync writes CSS variables onto an element (default: body), which overrides those tokens for the subtree because inline styles win over stylesheet rules for the same property.
+ Use a named preset (see BitThemePresets) with SetThemeAsync, then call ApplyBitThemeAsync with a partial BitTheme for runtime brand tweaks.
+
+
+
+
+ Presets, utilities, SSR
+
+ BitThemePresets exposes well-known bit-theme names (for example BitThemePresets.FluentDark).
+ BitThemeUtilities.ToCssVariables maps a BitTheme to a dictionary of CSS custom properties; BitThemeUtilities.Merge merges a child theme over a parent (same rules as BitThemeProvider).
+ BitThemeSerialization serializes or deserializes a theme as JSON for storage.
+ BitThemeColorDerivation.FillColorRoleFromMain can fill unset steps on BitThemeColorVariants from a single main hex color.
+ Bit.BlazorUI.Extras reuses the same design tokens (for example $clr-pri in SCSS) so preset and BitTheme overrides apply consistently.
+ For an optional early head script that reduces theme flash with persistence, see the BitThemeSsr section below.
+
+
+
+
+ await _bitThemeManager.SetThemeAsync(BitThemePresets.FluentDark);
+
+var json = BitThemeSerialization.Serialize(myTheme);
+var copy = BitThemeSerialization.Deserialize(json);
+
+BitThemeColorDerivation.FillColorRoleFromMain(myTheme.Color.Primary, "#1A86D8");
+
+
+
+
+
+ BitThemeSsr
+
+
+
+
+ BitThemeSsr helps reduce the wrong-theme flash on first paint when you use bit-theme-persist (and optional bit-theme, bit-theme-default, bit-theme-light, bit-theme-dark on <html>).
+ It injects a tiny script at the very start of <head> so bit-theme is restored from localStorage (when persist is enabled) or resolved for system before your Fluent stylesheets run.
+
+
+
+
+
+ Place it at the start of <head>, before bit BlazorUI / Fluent CSS links, so selectors such as :root[bit-theme="…"] match immediately.
+
+
+
+
+ In a Blazor Web App root document (for example Components/App.razor), emit the full script tag:
+
+
+
+ <head>
+ @@((MarkupString)@@BitThemeSsr.InlineHeadScript)
+ @@* stylesheet links after this *@@
+</head>
+
+
+
+ If you prefer your own <script> wrapper, use only the JavaScript body:
+
+
+
+ <script>@@BitThemeSsr.InlineHeadScriptBody</script>
+
+
+
+
+ Behavior matches the client BitTheme script: with bit-theme-persist, the stored key bit-current-theme is read; if the value is system, prefers-color-scheme picks the light or dark name from bit-theme-light / bit-theme-dark (defaulting to light and dark when those attributes are omitted).
+
diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Bit.BlazorUI.Tests.csproj b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Bit.BlazorUI.Tests.csproj
index de870a6aab..d9c48b7db9 100644
--- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Bit.BlazorUI.Tests.csproj
+++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Bit.BlazorUI.Tests.csproj
@@ -27,4 +27,10 @@
+
+
+ PreserveNewest
+
+
+
diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeColorDerivationTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeColorDerivationTests.cs
new file mode 100644
index 0000000000..3de1309bbf
--- /dev/null
+++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeColorDerivationTests.cs
@@ -0,0 +1,221 @@
+using System;
+using System;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.BlazorUI.Tests.Utils.Theme;
+
+[TestClass]
+public sealed class BitThemeColorDerivationTests
+{
+ // ── Guard clauses ──────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_NullVariants_DoesNotThrow()
+ {
+ // Should return silently – no exception expected.
+ BitThemeColorDerivation.FillColorRoleFromMain(null!, "#FF0000");
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_NullHex_DoesNotThrow()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, null!);
+ Assert.IsNull(v.Main);
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_EmptyHex_DoesNotThrow()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "");
+ Assert.IsNull(v.Main);
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_InvalidHex_DoesNotThrow()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "not-a-color");
+ // The catch block silences errors; variants remain null.
+ }
+
+ // ── All slots populated ────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_ValidColor_AllVariantsPopulated()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#3060A0");
+
+ Assert.IsNotNull(v.Main, "Main");
+ Assert.IsNotNull(v.MainHover, "MainHover");
+ Assert.IsNotNull(v.MainActive, "MainActive");
+ Assert.IsNotNull(v.Dark, "Dark");
+ Assert.IsNotNull(v.DarkHover, "DarkHover");
+ Assert.IsNotNull(v.DarkActive, "DarkActive");
+ Assert.IsNotNull(v.Light, "Light");
+ Assert.IsNotNull(v.LightHover, "LightHover");
+ Assert.IsNotNull(v.LightActive, "LightActive");
+ Assert.IsNotNull(v.Text, "Text");
+ }
+
+ // ── Pre-set values are never overwritten ───────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_PresetMainNotOverwritten()
+ {
+ const string preset = "#AABBCC";
+ var v = new BitThemeColorVariants { Main = preset };
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#FF0000");
+ Assert.AreEqual(preset, v.Main);
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_PresetLightNotOverwritten()
+ {
+ const string preset = "#FFFFFF";
+ var v = new BitThemeColorVariants { Light = preset };
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#3060A0");
+ Assert.AreEqual(preset, v.Light);
+ }
+
+ // ── Hex format ────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_ValidColor_HexValuesStartWithHash()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#3060A0");
+
+ foreach (var (name, value) in new[]
+ {
+ ("Main", v.Main),
+ ("MainHover", v.MainHover),
+ ("MainActive", v.MainActive),
+ ("Dark", v.Dark),
+ ("DarkHover", v.DarkHover),
+ ("DarkActive", v.DarkActive),
+ ("Light", v.Light),
+ ("LightHover", v.LightHover),
+ ("LightActive", v.LightActive),
+ })
+ {
+ Assert.IsTrue(value!.StartsWith('#'), $"{name} should start with '#' but was '{value}'");
+ }
+ }
+
+ // ── Dark variants are darker than Main ────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_DarkVariants_AreDarkerThanMain()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#3060A0");
+
+ var mainLum = Luminance(v.Main!);
+ var darkLum = Luminance(v.Dark!);
+ var dHoverLum = Luminance(v.DarkHover!);
+ var dActLum = Luminance(v.DarkActive!);
+
+ Assert.IsTrue(darkLum < mainLum, "Dark should be darker than Main");
+ Assert.IsTrue(dHoverLum < darkLum, "DarkHover should be darker than Dark");
+ Assert.IsTrue(dActLum < dHoverLum, "DarkActive should be darker than DarkHover");
+ }
+
+ // ── Light variants are lighter than Main ──────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_LightVariants_AreLighterThanMain()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#3060A0");
+
+ var mainLum = Luminance(v.Main!);
+ var lightLum = Luminance(v.Light!);
+
+ Assert.IsTrue(lightLum > mainLum, "Light should be lighter than Main");
+ }
+
+ // ── Light steps are distinct even for high-brightness colors ──────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_HighBrightnessColor_LightStepsAreDistinct()
+ {
+ // Pure white or near-white causes multiplicative scaling to collapse;
+ // additive offsets must keep steps distinguishable.
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#E8E8E8"); // high-v grey
+
+ // All three light variants must differ from Main.
+ Assert.AreNotEqual(v.Main, v.Light, "Light must differ from Main for high-v color");
+ Assert.AreNotEqual(v.Main, v.LightHover, "LightHover must differ from Main for high-v color");
+ Assert.AreNotEqual(v.Main, v.LightActive, "LightActive must differ from Main for high-v color");
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_HighBrightnessColor_LightStepsMutuallyDistinct()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#B0C8E0"); // mid-high brightness
+
+ Assert.AreNotEqual(v.Light, v.LightHover, "Light and LightHover must differ");
+ Assert.AreNotEqual(v.LightHover, v.LightActive, "LightHover and LightActive must differ");
+ Assert.AreNotEqual(v.Light, v.LightActive, "Light and LightActive must differ");
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_VeryHighBrightnessColor_LightStepsMutuallyDistinct()
+ {
+ // #D0D0D0 → v ≈ 0.816; all three additive steps (0.08/0.12/0.16) stay below 1.0
+ // and produce distinct hex values. Colors with v > 0.84 may still have LightActive
+ // clamp to white — that is an inherent ceiling, not a regression.
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#D0D0D0"); // v ≈ 0.816
+
+ Assert.AreNotEqual(v.Light, v.LightHover, "Light and LightHover must differ at high brightness");
+ Assert.AreNotEqual(v.LightHover, v.LightActive, "LightHover and LightActive must differ at high brightness");
+ }
+
+ // ── Text contrast suggestion ───────────────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_DarkBaseColor_TextIsWhite()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#1A1A2E");
+ Assert.AreEqual("#FFFFFF", v.Text);
+ }
+
+ [TestMethod]
+ public void FillColorRoleFromMain_LightBaseColor_TextIsBlack()
+ {
+ var v = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v, "#F0F0F0");
+ Assert.AreEqual("#000000", v.Text);
+ }
+
+ // ── Whitespace trimming ───────────────────────────────────────────────────
+
+ [TestMethod]
+ public void FillColorRoleFromMain_HexWithWhitespace_ParsedCorrectly()
+ {
+ var v1 = new BitThemeColorVariants();
+ var v2 = new BitThemeColorVariants();
+ BitThemeColorDerivation.FillColorRoleFromMain(v1, "#3060A0");
+ BitThemeColorDerivation.FillColorRoleFromMain(v2, " #3060A0 ");
+ Assert.AreEqual(v1.Main, v2.Main);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ /// Perceived luminance (0–1) from a #RRGGBB hex string.
+ private static double Luminance(string hex)
+ {
+ hex = hex.TrimStart('#');
+ var r = Convert.ToInt32(hex[..2], 16);
+ var g = Convert.ToInt32(hex[2..4], 16);
+ var b = Convert.ToInt32(hex[4..6], 16);
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
+ }
+}
diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs
new file mode 100644
index 0000000000..6dd5d1699c
--- /dev/null
+++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.BlazorUI.Tests.Utils.Theme;
+
+[TestClass]
+public sealed class BitThemeMapperContractTests
+{
+ private static readonly Regex CssVarRef = new(@"var\((--bit-[a-z0-9-]+)\)", RegexOptions.Compiled);
+
+ [TestMethod]
+ public void ThemeVariablesReferencedTokensAreEmittedByMapperWhenSet()
+ {
+ var scssPath = Path.Combine(AppContext.BaseDirectory, "theme-variables.scss");
+ Assert.IsTrue(File.Exists(scssPath), $"Missing {scssPath}; ensure theme-variables.scss is copied to output.");
+
+ var scss = File.ReadAllText(scssPath);
+ var expectedKeys = CssVarRef.Matches(scss)
+ .Select(m => m.Groups[1].Value)
+ .Distinct()
+ .ToHashSet(StringComparer.Ordinal);
+
+ var theme = new BitTheme();
+ FillAllStringProperties(theme, []);
+
+ var mapped = BitThemeUtilities.ToCssVariables(theme);
+
+ var missing = expectedKeys.Where(k => !mapped.ContainsKey(k)).ToArray();
+ CollectionAssert.AreEqual(Array.Empty(), missing, $"Mapper missing keys referenced in theme-variables.scss: {string.Join(", ", missing)}");
+ }
+
+ [TestMethod]
+ public void BitThemeSerializationRoundtripPreservesPrimaryColor()
+ {
+ var original = new BitTheme();
+ original.Color.Primary.Main = "#ABCDEF";
+
+ var json = BitThemeSerialization.Serialize(original);
+ var roundTrip = BitThemeSerialization.Deserialize(json);
+
+ Assert.AreEqual("#ABCDEF", roundTrip.Color.Primary.Main);
+ }
+
+ [TestMethod]
+ public void MergeChildOverridesParentForSingleProperty()
+ {
+ var parent = new BitTheme();
+ parent.Color.Primary.Main = "#111111";
+ parent.Color.Primary.MainHover = "#333333";
+
+ var child = new BitTheme();
+ child.Color.Primary.Main = "#222222";
+
+ var merged = BitThemeUtilities.Merge(child, parent);
+ Assert.AreEqual("#222222", merged.Color.Primary.Main);
+ Assert.AreEqual("#333333", merged.Color.Primary.MainHover);
+ }
+
+ private static void FillAllStringProperties(object? obj, HashSet