diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index e8237ea..4b08dce 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -65,6 +65,7 @@ internal sealed class TabSidebar TextMuted = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.TextMuted, colors)), Accent = accent, Hover = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceHover, colors)), + Surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)), IconFont = iconFont, IconSize = iconSize, }; @@ -84,6 +85,10 @@ internal sealed class TabSidebar public required uint TextMuted { get; init; } public required uint Accent { get; init; } public required uint Hover { get; init; } + + // What the row is actually drawn on, which is what a foreground has to + // clear -- not the theme's nominal background. + public required uint Surface { get; init; } public required ImFontPtr IconFont { get; init; } public required float IconSize { get; init; } } @@ -134,12 +139,20 @@ internal sealed class TabSidebar var contentColour = selected ? p.Text : ColourUtil.Lerp(p.TextMuted, p.Text, hoverAmount); + // The active row is filled with the accent, so the glyph on it has to + // clear that fill rather than the pane behind it. White on a pale violet + // accent measures 2.4:1, well under the 3:1 floor for icons, and that is + // exactly the case that was reported as unreadable. + var iconColour = selected + ? ColourUtil.EnsureContrast(p.Accent, p.Surface, 3f) + : ColourUtil.EnsureContrast(contentColour, p.Surface, 3f); + var iconX = origin.X + 12f * scale; dl.AddText( p.IconFont, p.IconSize, new Vector2(iconX, origin.Y + MetricsMath.Center(height, p.IconSize)), - selected ? p.Accent : contentColour, + iconColour, icon.ToIconString() ); @@ -149,7 +162,7 @@ internal sealed class TabSidebar iconX + p.IconSize + 9f * scale, origin.Y + MetricsMath.Center(height, labelSize.Y) ), - contentColour, + ColourUtil.EnsureContrast(contentColour, p.Surface, 4.5f), label ); diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index f4d246a..fb506b1 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -57,8 +57,8 @@ internal sealed class SurfaceBackdrop // the full height is exactly the case that bands, because its handful of // alpha steps each cover twenty-odd pixels. var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f); - dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x10 * strength * opacity) << 24), fade); - dl.DrawEdgeTint(min, max, (uint)(0x38 * strength * opacity) << 24, fade, fromBottom: true); + dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x26 * strength * opacity) << 24), fade); + dl.DrawEdgeTint(min, max, (uint)(0x44 * strength * opacity) << 24, fade, fromBottom: true); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); @@ -69,8 +69,8 @@ internal sealed class SurfaceBackdrop dl.AddRectFilledMultiColor( min, new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), - ColourUtil.ApplyAlpha(accent, 0.045f * opacity), - ColourUtil.ApplyAlpha(accent, 0.02f * opacity), + ColourUtil.ApplyAlpha(accent, 0.075f * opacity), + ColourUtil.ApplyAlpha(accent, 0.035f * opacity), accent & 0x00FFFFFFu, accent & 0x00FFFFFFu ); diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index 98e78a9..d5ab9bf 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -143,15 +143,17 @@ internal static class SegmentedControl MetricsMath.Center(height, textSize.Y) ), ColourUtil.ApplyAlpha( + // Measured against the surface each label actually sits on: + // the accent fill for the selected one, the track for the + // rest. A light/dark guess was not enough -- an accent can + // be mid-luminance and still fail against both. isSelected - // On the accent fill, so the readable colour depends on - // how light that accent is in the active theme. - ? ColourUtil.OnColour( - colors.SelectedAbgr, + ? ColourUtil.EnsureContrast( colors.SelectedLabelAbgr, - colors.TrackAbgr + colors.SelectedAbgr, + 4.5f ) - : colors.LabelAbgr, + : ColourUtil.EnsureContrast(colors.LabelAbgr, colors.TrackAbgr, 4.5f), alpha ), label diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 5c203a9..a8bd6ac 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -108,15 +108,73 @@ internal static class ColourUtil // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs - // Relative luminance of an ABGR colour, 0..1, using the sRGB coefficients. - // Alpha is ignored: this answers "is this surface light or dark", and a - // translucent light surface still reads light against the window behind it. + // Relative luminance per WCAG 2.1, 0..1. Channels are linearised first: + // sRGB is gamma-encoded, so averaging the raw bytes overstates the + // brightness of dark colours badly -- and almost every surface in this + // plugin is a dark colour. + // + // Alpha is ignored. This answers "is this light or dark", and a translucent + // light surface still reads light against what is behind it. internal static float Luminance(uint abgr) { - var r = (abgr & 0xFFu) / 255f; - var g = ((abgr >> 8) & 0xFFu) / 255f; - var b = ((abgr >> 16) & 0xFFu) / 255f; - return 0.2126f * r + 0.7152f * g + 0.0722f * b; + static float Linear(uint channel) + { + var c = channel / 255f; + return c <= 0.04045f ? c / 12.92f : MathF.Pow((c + 0.055f) / 1.055f, 2.4f); + } + + return 0.2126f * Linear(abgr & 0xFFu) + + 0.7152f * Linear((abgr >> 8) & 0xFFu) + + 0.0722f * Linear((abgr >> 16) & 0xFFu); + } + + // WCAG contrast ratio between two colours, 1.0 (identical) to 21.0 (black + // on white). 4.5 is the readability floor for body text, 3.0 for large text + // and for icons and other non-text marks. + internal static float ContrastRatio(uint a, uint b) + { + var la = Luminance(a); + var lb = Luminance(b); + var (hi, lo) = la > lb ? (la, lb) : (lb, la); + return (hi + 0.05f) / (lo + 0.05f); + } + + // Pushes a foreground away from its background until it clears the ratio, + // moving whichever direction the background is not. + // + // This is what a fixed palette cannot do. A theme picks one text colour, but + // the same text lands on a base surface, on a lit top edge and on an accent + // fill, and a value that reads on one of those can vanish on another. White + // on pale violet was the reported case. + internal static uint EnsureContrast(uint foregroundAbgr, uint backgroundAbgr, float minRatio) + { + if (ContrastRatio(foregroundAbgr, backgroundAbgr) >= minRatio) + return foregroundAbgr; + + // Direction is chosen by which end actually reaches further, not by + // whether the background counts as dark. A mid-luminance background can + // sit below 0.5 and still be far too light for white text: pale violet + // measures 0.39, so a "background is dark, brighten it" rule tried to + // make white whiter and got nowhere. + var towardWhite = + ContrastRatio(0xFFFFFFFFu, backgroundAbgr) > ContrastRatio(0xFF000000u, backgroundAbgr); + var best = foregroundAbgr; + + // Sixteen steps to full white or full black. Stops at the first value + // that clears, so a colour only travels as far as it has to and keeps + // as much of its hue as the ratio allows. + for (var i = 1; i <= 16; i++) + { + var t = i / 16f; + best = towardWhite + ? LerpTowardWhite(foregroundAbgr, t) + : LerpTowardBlack(foregroundAbgr, t); + + if (ContrastRatio(best, backgroundAbgr) >= minRatio) + return best; + } + + return best; } // Picks whichever of two candidates stands further from the background.