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 visited) + { + if (obj is null) return; + if (!visited.Add(obj)) return; + + foreach (var prop in obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanRead || !prop.CanWrite) continue; + + var propType = prop.PropertyType; + if (propType == typeof(string)) + { + prop.SetValue(obj, "1"); + continue; + } + + if (propType.IsClass && propType != typeof(string)) + { + var existing = prop.GetValue(obj); + if (existing is null) + { + existing = Activator.CreateInstance(propType) + ?? throw new InvalidOperationException($"Cannot create {propType.Name}"); + prop.SetValue(obj, existing); + } + + FillAllStringProperties(existing, visited); + } + } + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeSerializationTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeSerializationTests.cs new file mode 100644 index 0000000000..57332914d0 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeSerializationTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Utils.Theme; + +[TestClass] +public sealed class BitThemeSerializationTests +{ + // ── Serialize ────────────────────────────────────────────────────────────── + + [TestMethod] + public void Serialize_DefaultTheme_ProducesEmptyJsonObject() + { + var json = BitThemeSerialization.Serialize(new BitTheme()); + + Assert.AreEqual("{}", json.Trim()); + } + + [TestMethod] + public void Serialize_NullTheme_ProducesEmptyJsonObject() + { + var json = BitThemeSerialization.Serialize(null!); + + Assert.AreEqual("{}", json.Trim()); + } + + [TestMethod] + public void Serialize_SingleTokenSet_ContainsOnlyThatToken() + { + var theme = new BitTheme(); + theme.Color.Primary.Main = "#FF0000"; + + var json = BitThemeSerialization.Serialize(theme); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Only "color" branch should appear at root + var rootKeyCount = 0; + foreach (var _ in root.EnumerateObject()) rootKeyCount++; + Assert.AreEqual(1, rootKeyCount, "Expected exactly one top-level key."); + Assert.IsTrue(root.TryGetProperty("color", out var colorEl), "Expected 'color' key."); + Assert.IsFalse(root.TryGetProperty("boxShadow", out _), "'boxShadow' should be absent."); + Assert.IsFalse(root.TryGetProperty("typography", out _), "'typography' should be absent."); + Assert.IsFalse(root.TryGetProperty("spacing", out _), "'spacing' should be absent."); + + // Only "primary" inside color + Assert.IsTrue(colorEl.TryGetProperty("primary", out var primaryEl), "Expected 'primary' key."); + Assert.IsFalse(colorEl.TryGetProperty("secondary", out _), "'secondary' should be absent."); + Assert.AreEqual("#FF0000", primaryEl.GetProperty("main").GetString()); + } + + [TestMethod] + public void Serialize_UnsetSiblingVariantsOmitted() + { + var theme = new BitTheme(); + theme.Color.Primary.Main = "#111"; + theme.Color.Secondary.Main = "#222"; + + var json = BitThemeSerialization.Serialize(theme); + using var doc = JsonDocument.Parse(json); + var colorEl = doc.RootElement.GetProperty("color"); + + Assert.IsTrue(colorEl.TryGetProperty("primary", out _)); + Assert.IsTrue(colorEl.TryGetProperty("secondary", out _)); + Assert.IsFalse(colorEl.TryGetProperty("tertiary", out _), "'tertiary' should be absent."); + Assert.IsFalse(colorEl.TryGetProperty("error", out _), "'error' should be absent."); + } + + [TestMethod] + public void Serialize_TypographyTokenSet_EmitsOnlyThatVariant() + { + var theme = new BitTheme(); + theme.Typography.H1.FontSize = "2rem"; + + var json = BitThemeSerialization.Serialize(theme); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.IsTrue(root.TryGetProperty("typography", out var typoEl)); + Assert.IsFalse(root.TryGetProperty("color", out _)); + Assert.IsTrue(typoEl.TryGetProperty("h1", out var h1El)); + Assert.IsFalse(typoEl.TryGetProperty("h2", out _)); + Assert.AreEqual("2rem", h1El.GetProperty("fontSize").GetString()); + } + + [TestMethod] + public void Serialize_UsesCamelCase() + { + var theme = new BitTheme(); + theme.Color.Primary.MainHover = "#AAA"; + + var json = BitThemeSerialization.Serialize(theme); + using var doc = JsonDocument.Parse(json); + + Assert.IsTrue(doc.RootElement + .GetProperty("color") + .GetProperty("primary") + .TryGetProperty("mainHover", out _), + "Expected camelCase 'mainHover'."); + } + + [TestMethod] + public void Serialize_BoxShadowTokenSet_EmitsBoxShadow() + { + var theme = new BitTheme(); + theme.BoxShadow.Sm = "0 1px 3px rgba(0,0,0,.12)"; + + var json = BitThemeSerialization.Serialize(theme); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.IsTrue(root.TryGetProperty("boxShadow", out var bsEl)); + Assert.IsFalse(root.TryGetProperty("color", out _)); + Assert.AreEqual("0 1px 3px rgba(0,0,0,.12)", bsEl.GetProperty("sm").GetString()); + } + + // ── Deserialize ──────────────────────────────────────────────────────────── + + [TestMethod] + public void Deserialize_EmptyJson_ReturnsDefaultTheme() + { + var theme = BitThemeSerialization.Deserialize("{}"); + + Assert.IsNotNull(theme); + Assert.IsNull(theme.Color.Primary.Main); + } + + [TestMethod] + public void Deserialize_NullOrWhitespace_ReturnsDefaultTheme() + { + Assert.IsNotNull(BitThemeSerialization.Deserialize(null!)); + Assert.IsNotNull(BitThemeSerialization.Deserialize("")); + Assert.IsNotNull(BitThemeSerialization.Deserialize(" ")); + } + + [TestMethod] + public void Deserialize_ValidJson_RestoresTokenValue() + { + const string json = """{"color":{"primary":{"main":"#ABCDEF"}}}"""; + + var theme = BitThemeSerialization.Deserialize(json); + + Assert.AreEqual("#ABCDEF", theme.Color.Primary.Main); + } + + // ── Round-trip ───────────────────────────────────────────────────────────── + + [TestMethod] + public void RoundTrip_SingleToken_PreservesValue() + { + var original = new BitTheme(); + original.Color.Primary.Main = "#ABCDEF"; + + var roundTrip = BitThemeSerialization.Deserialize(BitThemeSerialization.Serialize(original)); + + Assert.AreEqual("#ABCDEF", roundTrip.Color.Primary.Main); + } + + [TestMethod] + public void RoundTrip_MultipleTokensAcrossSections_AllPreserved() + { + var original = new BitTheme(); + original.Color.Primary.Main = "#111"; + original.Color.Error.Main = "#F00"; + original.Typography.H1.FontSize = "2rem"; + original.BoxShadow.Sm = "0 1px 2px #000"; + + var roundTrip = BitThemeSerialization.Deserialize(BitThemeSerialization.Serialize(original)); + + Assert.AreEqual("#111", roundTrip.Color.Primary.Main); + Assert.AreEqual("#F00", roundTrip.Color.Error.Main); + Assert.AreEqual("2rem", roundTrip.Typography.H1.FontSize); + Assert.AreEqual("0 1px 2px #000", roundTrip.BoxShadow.Sm); + } + + [TestMethod] + public void RoundTrip_DefaultTheme_ProducesEmptyJsonAndRestoresDefaults() + { + var json = BitThemeSerialization.Serialize(new BitTheme()); + var roundTrip = BitThemeSerialization.Deserialize(json); + + Assert.AreEqual("{}", json.Trim()); + Assert.IsNotNull(roundTrip); + Assert.IsNull(roundTrip.Color.Primary.Main); + Assert.IsNull(roundTrip.Typography.H1.FontSize); + Assert.IsNull(roundTrip.BoxShadow.Sm); + } +}