< Summary

Information
Class: Allyaria.Theming.Styles.AllyariaPalette
Assembly: Allyaria.Theming
File(s): /home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Styles/AllyariaPalette.cs
Line coverage
100%
Covered lines: 113
Uncovered lines: 0
Coverable lines: 113
Total lines: 423
Line coverage: 100%
Branch coverage
100%
Covered branches: 52
Total branches: 52
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_BackgroundColor()100%22100%
get_BackgroundImage()100%11100%
get_BorderColor()100%22100%
get_BorderRadius()100%11100%
get_BorderStyle()100%22100%
get_BorderWidth()100%22100%
get_ForegroundColor()100%44100%
Cascade(...)100%2020100%
ToCss()100%66100%
ToCssVars(...)100%88100%
ToDisabledPalette(...)100%22100%
ToHoverPalette(...)100%44100%

File(s)

/home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Styles/AllyariaPalette.cs

#LineLine coverage
 1using Allyaria.Theming.Constants;
 2using Allyaria.Theming.Helpers;
 3using Allyaria.Theming.Values;
 4using System.Text;
 5using System.Text.RegularExpressions;
 6
 7namespace Allyaria.Theming.Styles;
 8
 9/// <summary>
 10/// Represents an immutable, strongly typed palette used by the Allyaria theme engine to compute effective foreground,
 11/// background, and border styles (including hover variants) with the documented precedence rules.
 12/// </summary>
 13/// <remarks>
 14/// This type is designed for inline style generation and CSS custom property emission. It follows Allyaria theming
 15/// precedence—background images override region colors, explicit overrides beat defaults, and borders are opt-in. See
 16/// <see cref="ToCss" /> and <see cref="ToCssVars" />.
 17/// </remarks>
 18public readonly record struct AllyariaPalette
 19{
 20    /// <summary>The base background color as provided, before any precedence (e.g., border presence) is applied.</summa
 21    private readonly AllyariaColorValue? _backgroundColor;
 22
 23    /// <summary>Optional background image or <see langword="null" /> when no image was provided.</summary>
 24    private readonly AllyariaImageValue? _backgroundImage;
 25
 26    /// <summary>Whether or not the background image is stretched.</summary>
 27    private readonly bool _backgroundImageStretch;
 28
 29    /// <summary>
 30    /// Optional explicit border color as provided by the caller; may be <see langword="null" /> to signal defaulting to
 31    /// <see cref="BackgroundColor" /> when a border is present.
 32    /// </summary>
 33    private readonly AllyariaColorValue? _borderColor;
 34
 35    /// <summary>Optional border radius value (e.g., <c>4px</c>) or <see langword="null" /> to omit the declaration.</su
 36    private readonly AllyariaStringValue? _borderRadius;
 37
 38    /// <summary>Border style token (e.g., <c>solid</c>). Defaults to <c>solid</c> when not supplied.</summary>
 39    private readonly AllyariaStringValue? _borderStyle;
 40
 41    /// <summary>
 42    /// Optional border width (e.g., <c>1px</c>) or <see langword="null" /> when no border should be rendered.
 43    /// </summary>
 44    private readonly int? _borderWidth;
 45
 46    /// <summary>
 47    /// Optional explicit foreground color as provided by the caller; may be <see langword="null" /> to signal contrast-
 48    /// defaulting from <see cref="BackgroundColor" />.
 49    /// </summary>
 50    private readonly AllyariaColorValue? _foregroundColor;
 51
 52    /// <summary>Initializes a new immutable <see cref="AllyariaPalette" />.</summary>
 53    /// <param name="backgroundColor">
 54    /// Optional base background color; defaults to <see cref="Colors.White" /> when not
 55    /// provided.
 56    /// </param>
 57    /// <param name="foregroundColor">
 58    /// Optional explicit foreground color; when not provided, it is computed from <see cref="BackgroundColor" /> lightn
 59    /// contrast.
 60    /// </param>
 61    /// <param name="backgroundImage">
 62    /// An optional background image URL. When non-empty, this is transformed into a composite
 63    /// <c>linear-gradient(...), url("...")</c> value to ensure readability. Whitespace is trimmed and the URL is lower-
 64    /// for stability. When empty or whitespace, no image is used.
 65    /// </param>
 66    /// <param name="backgroundImageStretch">
 67    /// An optional boolean for determining if the background image is stretched or tiled. Defaults to stretched.
 68    /// </param>
 69    /// <param name="borderWidth">
 70    /// Border width in CSS pixels. Values &lt;= 0 omit the border entirely; values &gt; 0 render as <c>&lt;width&gt;px<
 71    /// </param>
 72    /// <param name="borderColor">
 73    /// Optional explicit border color. When not provided but a border is present, the border color defaults to
 74    /// <see cref="BackgroundColor" /> to maintain visual cohesion.
 75    /// </param>
 76    /// <param name="borderStyle">
 77    /// Optional border style token (e.g., <c>solid</c>, <c>dashed</c>). Defaults to <c>solid</c> when not supplied.
 78    /// </param>
 79    /// <param name="borderRadius">
 80    /// Optional border radius (e.g., <c>4px</c>). When provided and a border is present, a corresponding <c>border-radi
 81    /// declaration is emitted.
 82    /// </param>
 83    public AllyariaPalette(AllyariaColorValue? backgroundColor = null,
 84        AllyariaColorValue? foregroundColor = null,
 85        AllyariaImageValue? backgroundImage = null,
 86        bool backgroundImageStretch = true,
 87        int? borderWidth = 0,
 88        AllyariaColorValue? borderColor = null,
 89        AllyariaStringValue? borderStyle = null,
 90        AllyariaStringValue? borderRadius = null)
 91    {
 17492        _backgroundColor = backgroundColor;
 17493        _backgroundImage = backgroundImage;
 17494        _backgroundImageStretch = backgroundImageStretch;
 17495        _borderColor = borderColor;
 17496        _borderRadius = borderRadius;
 17497        _borderStyle = borderStyle;
 17498        _borderWidth = borderWidth;
 17499        _foregroundColor = foregroundColor;
 174100    }
 101
 102    /// <summary>Gets the effective background color after precedence is applied.</summary>
 368103    public AllyariaColorValue BackgroundColor => _backgroundColor ?? Colors.White;
 104
 105    /// <summary>
 106    /// Gets the effective background image declaration value, or <see langword="null" /> when no image is set.
 107    /// </summary>
 124108    public AllyariaImageValue? BackgroundImage => _backgroundImage;
 109
 110    /// <summary>
 111    /// Gets the effective border color. If there is no border, the color is <see cref="Colors.Transparent" />.
 112    /// </summary>
 56113    public AllyariaColorValue BorderColor => _borderColor ?? BackgroundColor;
 114
 115    /// <summary>Gets the effective border radius declaration value, or <see langword="null" /> when not set.</summary>
 122116    public AllyariaStringValue? BorderRadius => _borderRadius;
 117
 118    /// <summary>Gets the border style token (e.g., <c>solid</c>).</summary>
 36119    public AllyariaStringValue BorderStyle => _borderStyle ?? new AllyariaStringValue("solid");
 120
 121    /// <summary>
 122    /// Gets the effective border width declaration value, or <see langword="null" /> when no border should be rendered.
 123    /// </summary>
 124    public AllyariaStringValue? BorderWidth
 172125        => _borderWidth > 0
 172126            ? new AllyariaStringValue($"{_borderWidth}px")
 172127            : null;
 128
 129    /// <summary>
 130    /// Gets the resolved foreground color to render against <see cref="BackgroundColor" />:
 131    /// <list type="bullet">
 132    ///     <item>
 133    ///         <description>
 134    ///         If no explicit foreground is set (<c>_foregroundColor</c> is <see langword="null" />), the getter choose
 135    ///         whichever of <see cref="Colors.White" /> or <see cref="Colors.Black" /> produces the higher WCAG contras
 136    ///         against <see cref="BackgroundColor" />.
 137    ///         </description>
 138    ///     </item>
 139    ///     <item>
 140    ///         <description>
 141    ///         If an explicit foreground is provided, the getter ensures it meets at least the minimum accessibility co
 142    ///         ratio (default 4.5:1 for normal text) using <see cref="ColorHelper" />; it will preserve the original hu
 143    ///         possible and, if needed, adjust lightness or mix toward black/white to reach the target.
 144    ///         </description>
 145    ///     </item>
 146    /// </list>
 147    /// </summary>
 148    /// <remarks>
 149    /// This property uses real WCAG contrast calculations rather than brightness heuristics (e.g. V or H from HSV). It 
 150    /// text remains readable and accessible on the current background color while honoring any explicitly provided fore
 151    /// when possible.
 152    /// </remarks>
 153    public AllyariaColorValue ForegroundColor
 154    {
 155        get
 156        {
 190157            if (_foregroundColor is null)
 158            {
 12159                var rWhite = ColorHelper.ContrastRatio(Colors.White, BackgroundColor);
 12160                var rBlack = ColorHelper.ContrastRatio(Colors.Black, BackgroundColor);
 161
 12162                return rWhite >= rBlack
 12163                    ? Colors.White
 12164                    : Colors.Black;
 165            }
 166
 167            // Ensure the explicit color meets contrast over the *effective* BackgroundColor
 178168            var result = ColorHelper.EnsureMinimumContrast(_foregroundColor, BackgroundColor, 4.5);
 169
 178170            return result.ForegroundColor;
 171        }
 172    }
 173
 174    /// <summary>
 175    /// Cascades the palette by applying overrides, falling back to the original base values of this instance when not p
 176    /// (no reapplication of effective/derived precedence).
 177    /// </summary>
 178    /// <param name="backgroundColor">Optional new background color (base).</param>
 179    /// <param name="foregroundColor">Optional new foreground color (base).</param>
 180    /// <param name="backgroundImage">
 181    /// Optional new background image URL (unwrapped; overlaying is handled by <see cref="BackgroundImage" />).
 182    /// </param>
 183    /// <param name="backgroundImageStretch">An optional boolean for determining if the background image is stretched or
 184    /// <param name="borderWidth">
 185    /// Optional new border width in CSS pixels; values &lt; 0 are treated as <c>null</c> (no
 186    /// border).
 187    /// </param>
 188    /// <param name="borderColor">Optional new border color (base).</param>
 189    /// <param name="borderStyle">Optional new border style token.</param>
 190    /// <param name="borderRadius">Optional new border radius token.</param>
 191    /// <returns>
 192    /// A new <see cref="AllyariaPalette" /> with the specified overrides applied atop this instance’s base values.
 193    /// </returns>
 194    public AllyariaPalette Cascade(AllyariaColorValue? backgroundColor = null,
 195        AllyariaColorValue? foregroundColor = null,
 196        AllyariaImageValue? backgroundImage = null,
 197        bool? backgroundImageStretch = null,
 198        int? borderWidth = null,
 199        AllyariaColorValue? borderColor = null,
 200        AllyariaStringValue? borderStyle = null,
 201        AllyariaStringValue? borderRadius = null)
 202    {
 68203        var sanitizedBorderWidth = borderWidth is < 0
 68204            ? null
 68205            : borderWidth;
 206
 68207        var newBackgroundColor = backgroundColor ?? _backgroundColor;
 68208        var newBackgroundImage = backgroundImage ?? _backgroundImage;
 68209        var newBackgroundImageStretch = backgroundImageStretch ?? _backgroundImageStretch;
 68210        var newBorderColor = borderColor ?? _borderColor;
 68211        var newBorderStyle = borderStyle ?? _borderStyle;
 68212        var newBorderRadius = borderRadius ?? _borderRadius;
 68213        var newBorderWidth = sanitizedBorderWidth ?? _borderWidth;
 68214        var newForegroundColor = foregroundColor ?? _foregroundColor;
 215
 68216        return new AllyariaPalette(
 68217            newBackgroundColor,
 68218            newForegroundColor,
 68219            newBackgroundImage,
 68220            newBackgroundImageStretch,
 68221            newBorderWidth,
 68222            newBorderColor,
 68223            newBorderStyle,
 68224            newBorderRadius
 68225        );
 226    }
 227
 228    /// <summary>
 229    /// Builds a string of inline CSS declarations (e.g., <c>color: #fff; background-color: #000;</c>) that applies the 
 230    /// palette with the documented precedence (background image &gt; background color; explicit overrides &gt; defaults
 231    /// border rendered only when width &gt; 0).
 232    /// </summary>
 233    /// <returns>A CSS declaration string suitable for inline <c>style</c> attributes.</returns>
 234    public string ToCss()
 235    {
 58236        var builder = new StringBuilder();
 58237        builder.Append(BackgroundColor.ToCss("background-color"));
 58238        builder.Append(ForegroundColor.ToCss("color"));
 239
 58240        if (BackgroundImage is not null)
 241        {
 8242            builder.Append(BackgroundImage.ToCssBackground(BackgroundColor, _backgroundImageStretch));
 243        }
 244
 58245        if (BorderWidth is not null)
 246        {
 16247            builder.Append(BorderColor.ToCss("border-color"));
 16248            builder.Append(BorderStyle.ToCss("border-style"));
 16249            builder.Append(BorderWidth.ToCss("border-width"));
 250        }
 251
 58252        if (BorderRadius is not null)
 253        {
 8254            builder.Append(BorderRadius.ToCss("border-radius"));
 255        }
 256
 58257        return builder.ToString();
 258    }
 259
 260    /// <summary>
 261    /// Builds a string of CSS custom property declarations for theming. The method normalizes the optional
 262    /// <paramref name="prefix" /> by trimming whitespace and dashes, converting to lowercase, and replacing spaces with
 263    /// hyphens. If no usable prefix remains, variables are emitted with the default <c>--aa-</c> prefix; otherwise, the
 264    /// computed prefix is applied (e.g., <c>--mytheme-color</c>, <c>--mytheme-background-color</c>).
 265    /// </summary>
 266    /// <param name="prefix">
 267    /// An optional string used to namespace the CSS variables. May contain spaces or leading/trailing dashes, which are
 268    /// normalized before use. If empty or whitespace, defaults to <c>--aa-</c>.
 269    /// </param>
 270    /// <returns>
 271    /// A CSS declaration string defining theme variables (foreground, background, border, and radius) that can be consu
 272    /// component CSS. If a background image is present, a <c>--{prefix}-background-image</c> variable is emitted and
 273    /// background color variables are omitted.
 274    /// </returns>
 275    /// <remarks>
 276    /// Border and radius variables are included only when explicitly set. This ensures that only relevant theming prope
 277    /// are emitted, keeping CSS concise.
 278    /// </remarks>
 279    public string ToCssVars(string prefix = "")
 280    {
 38281        var basePrefix = Regex.Replace(prefix, @"[\s-]+", "-").Trim('-').ToLowerInvariant();
 282
 38283        basePrefix = string.IsNullOrWhiteSpace(prefix)
 38284            ? "--aa-"
 38285            : $"--{basePrefix}-";
 286
 38287        var builder = new StringBuilder();
 38288        builder.Append(ForegroundColor.ToCss($"{basePrefix}color"));
 38289        builder.Append(ForegroundColor.ToCss($"{basePrefix}background-color"));
 290
 38291        if (BackgroundImage is not null)
 292        {
 4293            builder.Append(BackgroundImage.ToCssVarsBackground(basePrefix, BackgroundColor, _backgroundImageStretch));
 294        }
 295
 38296        if (BorderWidth is not null)
 297        {
 4298            builder.Append(BorderColor.ToCss($"{basePrefix}border-color"));
 4299            builder.Append(BorderStyle.ToCss($"{basePrefix}border-style"));
 4300            builder.Append(BorderWidth.ToCss($"{basePrefix}border-width"));
 301        }
 302
 38303        if (BorderRadius is not null)
 304        {
 2305            builder.Append(BorderRadius.ToCss($"{prefix}border-radius"));
 306        }
 307
 38308        return builder.ToString();
 309    }
 310
 311    /// <summary>
 312    /// Produces a derived palette for the <c>[disabled]</c> state by desaturating the background and gently compressing
 313    /// Value (V) toward the mid range to reduce emphasis. The foreground is then contrast-corrected to a relaxed minimu
 314    /// suitable for UI affordances (3.0:1).
 315    /// </summary>
 316    /// <param name="desaturateBy">
 317    /// Percentage points to subtract from the background Saturation (S). Default is <c>60</c>, yielding a muted surface
 318    /// </param>
 319    /// <param name="valueBlendTowardMid">
 320    /// Blend factor in [0..1] that moves background Value (V) toward mid (50). Default is <c>0.15</c> (15% toward mid).
 321    /// </param>
 322    /// <param name="minimumContrast">
 323    /// Minimum required contrast ratio for disabled foreground over the derived background; defaults to <c>3.0</c>.
 324    /// </param>
 325    /// <returns>A new <see cref="AllyariaPalette" /> suitable for the disabled state.</returns>
 326    /// <remarks>
 327    /// *Hue is preserved* for the background; only S/V are adjusted. If a border is present, its hue is preserved and i
 328    /// desaturated and value-compressed in tandem with the background to avoid visual dominance. Background images are 
 329    /// unchanged (image precedence still applies).
 330    /// </remarks>
 331    public AllyariaPalette ToDisabledPalette(double desaturateBy = 60.0,
 332        double valueBlendTowardMid = 0.15,
 333        double minimumContrast = 3.0)
 334    {
 22335        var baseBg = BackgroundColor;
 336
 22337        var disabledBg = AllyariaColorValue.FromHsva(
 22338            baseBg.H,
 22339            Math.Max(0.0, baseBg.S - desaturateBy),
 22340            ColorHelper.Blend(baseBg.V, 50.0, valueBlendTowardMid)
 22341        );
 342
 22343        AllyariaColorValue? disabledBorder = null;
 344
 22345        if (BorderWidth is not null)
 346        {
 2347            var baseBorder = BorderColor;
 348
 2349            disabledBorder = AllyariaColorValue.FromHsva(
 2350                baseBorder.H,
 2351                Math.Max(0.0, baseBorder.S - desaturateBy),
 2352                ColorHelper.Blend(baseBorder.V, 50.0, valueBlendTowardMid)
 2353            );
 354        }
 355
 356        // Start from existing foreground (effective), then ensure a relaxed contrast target against the disabled backgr
 22357        var candidateFg = ForegroundColor;
 22358        var disabledFg = ColorHelper.EnsureMinimumContrast(candidateFg, disabledBg, minimumContrast).ForegroundColor;
 359
 22360        return Cascade(
 22361            disabledBg,
 22362            disabledFg,
 22363            borderColor: disabledBorder
 22364        );
 365    }
 366
 367    /// <summary>
 368    /// Produces a derived palette intended for the <c>:hover</c> state by nudging the background (and border, if presen
 369    /// along the HSV Value rail to increase perceived affordance while preserving hue. The foreground is then
 370    /// contrast-corrected against the new background to meet WCAG AA for body text (4.5:1).
 371    /// </summary>
 372    /// <param name="backgroundDeltaV">
 373    /// The absolute Value (V) change in percentage points to apply to <see cref="BackgroundColor" />. On light backgrou
 374    /// ≥ 50) the value is decreased; on dark backgrounds it is increased. Default is <c>6</c>.
 375    /// </param>
 376    /// <param name="borderDeltaV">
 377    /// The absolute Value (V) change in percentage points to apply to <see cref="BorderColor" /> when a border is prese
 378    /// Mirrors the direction used for the background. Default is <c>8</c>.
 379    /// </param>
 380    /// <param name="minimumContrast">
 381    /// Minimum required contrast ratio for the foreground over the derived background; defaults to <c>4.5</c> (WCAG AA)
 382    /// </param>
 383    /// <returns>A new <see cref="AllyariaPalette" /> suitable for the hover state.</returns>
 384    /// <remarks>
 385    /// This method does not alter the background image. If a background image is active, it remains in effect (image
 386    /// precedence still applies), but the derived background color—while not painted—still participates in computing a
 387    /// readable foreground as a fallback.
 388    /// </remarks>
 389    public AllyariaPalette ToHoverPalette(double backgroundDeltaV = 6.0,
 390        double borderDeltaV = 8.0,
 391        double minimumContrast = 4.5)
 392    {
 18393        var baseBg = BackgroundColor;
 394
 395        // Direction: lighten for dark surfaces, darken for light surfaces.
 18396        var direction = baseBg.V >= 50.0
 18397            ? -1.0
 18398            : +1.0;
 399
 18400        var hoverBg = AllyariaColorValue.FromHsva(baseBg.H, baseBg.S, baseBg.V + direction * backgroundDeltaV);
 401
 18402        AllyariaColorValue? hoverBorder = null;
 403
 18404        if (BorderWidth is not null) // only compute if a border is rendered
 405        {
 4406            var baseBorder = BorderColor;
 407
 4408            hoverBorder = AllyariaColorValue.FromHsva(
 4409                baseBorder.H, baseBorder.S, baseBorder.V + direction * borderDeltaV
 4410            );
 411        }
 412
 413        // Ensure readable foreground against the derived background while preserving hue where possible.
 18414        var fgResolved = ColorHelper.EnsureMinimumContrast(ForegroundColor, hoverBg, minimumContrast)
 18415            .ForegroundColor;
 416
 18417        return Cascade(
 18418            hoverBg,
 18419            fgResolved,
 18420            borderColor: hoverBorder
 18421        );
 422    }
 423}