diff --git a/Directory.Packages.props b/Directory.Packages.props
index 378664d..02d787b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,6 +12,7 @@
+
diff --git a/Semantics.Color/AccessibilityLevel.cs b/Semantics.Color/AccessibilityLevel.cs
new file mode 100644
index 0000000..a54a588
--- /dev/null
+++ b/Semantics.Color/AccessibilityLevel.cs
@@ -0,0 +1,18 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+/// WCAG 2.x contrast conformance levels, ordered so that higher is stricter.
+public enum AccessibilityLevel
+{
+ /// Does not meet the AA contrast threshold.
+ Fail = 0,
+
+ /// Meets the WCAG AA contrast threshold.
+ AA = 1,
+
+ /// Meets the WCAG AAA contrast threshold.
+ AAA = 2,
+}
diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs
new file mode 100644
index 0000000..de25aed
--- /dev/null
+++ b/Semantics.Color/Color.Conversions.cs
@@ -0,0 +1,149 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+using System.Numerics;
+
+public readonly partial record struct Color
+{
+ /// Creates a linear color from a gamma-encoded .
+ /// The sRGB color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromSrgb(Srgb srgb, double a = 1.0) => srgb.ToLinear(a);
+
+ /// Creates a linear color from gamma-encoded sRGB channels.
+ /// sRGB red channel (0..1).
+ /// sRGB green channel (0..1).
+ /// sRGB blue channel (0..1).
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromSrgb(double r, double g, double b, double a = 1.0) => new Srgb(r, g, b).ToLinear(a);
+
+ /// Converts this linear color to gamma-encoded .
+ /// The sRGB equivalent (alpha dropped).
+ public Srgb ToSrgb() => Srgb.FromLinear(this);
+
+ /// Converts to a gamma-encoded sRGB (float) — the value ImGui expects.
+ /// A float vector of sRGB RGB plus alpha.
+ public Vector4 ToSrgbVector4()
+ {
+ Srgb s = ToSrgb();
+ return new Vector4((float)s.R, (float)s.G, (float)s.B, (float)A);
+ }
+
+ /// Converts to a gamma-encoded sRGB (float), dropping alpha.
+ /// A float vector of sRGB RGB.
+ public Vector3 ToSrgbVector3()
+ {
+ Srgb s = ToSrgb();
+ return new Vector3((float)s.R, (float)s.G, (float)s.B);
+ }
+
+ /// Creates a linear color from a hex string: #RGB, #RRGGBB, or #RRGGBBAA (leading '#' optional). Channels are interpreted as sRGB.
+ /// The hex color string.
+ /// The linear-RGB color.
+ /// Thrown when is null.
+ /// Thrown when is not a recognised hex length.
+ public static Color FromHex(string hex)
+ {
+ Ensure.NotNull(hex);
+
+ string h = hex.StartsWith('#') ? hex[1..] : hex;
+
+ if (h.Length == 3)
+ {
+ h = new string([h[0], h[0], h[1], h[1], h[2], h[2]]);
+ }
+
+ if (h.Length is not (6 or 8))
+ {
+ throw new ArgumentException("Hex color must be #RGB, #RRGGBB, or #RRGGBBAA.", nameof(hex));
+ }
+
+ byte r = ParseByte(h, 0);
+ byte g = ParseByte(h, 2);
+ byte b = ParseByte(h, 4);
+ byte a = h.Length == 8 ? ParseByte(h, 6) : (byte)255;
+ return FromBytes(r, g, b, a);
+ }
+
+ /// Converts to an uppercase hex string: #RRGGBB, or #RRGGBBAA when alpha is not fully opaque.
+ /// The hex string.
+ public string ToHex()
+ {
+ (byte r, byte g, byte b, byte a) = ToBytes();
+ return a == 255
+ ? $"#{r:X2}{g:X2}{b:X2}"
+ : $"#{r:X2}{g:X2}{b:X2}{a:X2}";
+ }
+
+ /// Creates a linear color from 8-bit sRGB channels.
+ /// sRGB red byte.
+ /// sRGB green byte.
+ /// sRGB blue byte.
+ /// Alpha byte (default 255).
+ /// The linear-RGB color.
+ public static Color FromBytes(byte r, byte g, byte b, byte a = 255) =>
+ FromSrgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0);
+
+ /// Converts to 8-bit sRGB channels plus an alpha byte.
+ /// The rounded sRGB byte tuple.
+ public (byte R, byte G, byte B, byte A) ToBytes()
+ {
+ Srgb s = ToSrgb();
+ return (ToByte(s.R), ToByte(s.G), ToByte(s.B), ToByte(A));
+ }
+
+ private static byte ParseByte(string hex, int index) =>
+ Convert.ToByte(hex.Substring(index, 2), 16);
+
+ /// Converts this linear color to .
+ /// The Oklab equivalent.
+ public Oklab ToOklab() => Oklab.FromColor(this);
+
+ /// Creates a linear color from an value.
+ /// The Oklab color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromOklab(Oklab oklab, double a = 1.0) => oklab.ToColor(a);
+
+ /// Converts this linear color to .
+ /// The Oklch equivalent.
+ public Oklch ToOklch() => Oklab.FromColor(this).ToOklch();
+
+ /// Creates a linear color from an value.
+ /// The Oklch color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromOklch(Oklch oklch, double a = 1.0) => oklch.ToOklab().ToColor(a);
+
+ /// Converts this linear color to (via sRGB).
+ /// The HSL equivalent.
+ public Hsl ToHsl() => Hsl.FromSrgb(ToSrgb());
+
+ /// Creates a linear color from an value (via sRGB).
+ /// The HSL color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromHsl(Hsl hsl, double a = 1.0) => FromSrgb(hsl.ToSrgb(), a);
+
+ /// Converts this linear color to (via sRGB).
+ /// The HSV equivalent.
+ public Hsv ToHsv() => Hsv.FromSrgb(ToSrgb());
+
+ /// Creates a linear color from an value (via sRGB).
+ /// The HSV color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromHsv(Hsv hsv, double a = 1.0) => FromSrgb(hsv.ToSrgb(), a);
+
+ private static byte ToByte(double channel)
+ {
+ double scaled = Math.Round(Clamp01(channel) * 255.0);
+ return (byte)scaled;
+ }
+}
diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs
new file mode 100644
index 0000000..9273eb3
--- /dev/null
+++ b/Semantics.Color/Color.Operations.cs
@@ -0,0 +1,171 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+using System.Collections.Generic;
+
+public readonly partial record struct Color
+{
+ /// Gets the WCAG relative luminance of this color (computed on the linear channels).
+ public double RelativeLuminance => (0.2126 * R) + (0.7152 * G) + (0.0722 * B);
+
+ /// Computes the WCAG contrast ratio (1..21) between this color and another.
+ /// The other color.
+ /// The contrast ratio, from 1 (identical luminance) to 21 (black vs white).
+ public double ContrastRatio(Color other)
+ {
+ double l1 = RelativeLuminance;
+ double l2 = other.RelativeLuminance;
+ double lighter = Math.Max(l1, l2);
+ double darker = Math.Min(l1, l2);
+ return (lighter + 0.05) / (darker + 0.05);
+ }
+
+ /// Rates the contrast of this color against a background per WCAG.
+ /// The background color.
+ /// True for large text (lower thresholds).
+ /// The highest the pair satisfies.
+ public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false)
+ {
+ double contrast = ContrastRatio(background);
+ if (contrast >= (largeText ? 4.5 : 7.0))
+ {
+ return AccessibilityLevel.AAA;
+ }
+
+ return contrast >= (largeText ? 3.0 : 4.5) ? AccessibilityLevel.AA : AccessibilityLevel.Fail;
+ }
+
+ ///
+ /// Adjusts this color's Oklab lightness (preserving hue and chroma) until it meets the requested
+ /// contrast level against a background. Returns this color unchanged if already sufficient or if
+ /// no adjustment can reach the target.
+ ///
+ /// The background color.
+ /// The desired conformance level.
+ /// True for large text (lower thresholds).
+ /// An adjusted color, clamped to gamut.
+ public Color AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)
+ {
+ double required = target switch
+ {
+ AccessibilityLevel.AAA => largeText ? 4.5 : 7.0,
+ AccessibilityLevel.AA => largeText ? 3.0 : 4.5,
+ _ => 1.0,
+ };
+
+ if (ContrastRatio(background) >= required)
+ {
+ return this;
+ }
+
+ Oklab lab = ToOklab();
+ double alpha = A;
+ bool goLighter = background.RelativeLuminance < 0.5;
+ double lo = goLighter ? lab.L : 0.0;
+ double hi = goLighter ? 1.0 : lab.L;
+
+ // Contrast increases monotonically as L moves toward the chosen extreme; binary-search the
+ // smallest movement that meets the requirement.
+ for (int i = 0; i < 30; i++)
+ {
+ double mid = (lo + hi) / 2.0;
+ Color candidate = Candidate(lab, mid);
+ bool meets = candidate.ContrastRatio(background) >= required;
+ if (goLighter)
+ {
+ if (meets)
+ {
+ hi = mid;
+ }
+ else
+ {
+ lo = mid;
+ }
+ }
+ else if (meets)
+ {
+ lo = mid;
+ }
+ else
+ {
+ hi = mid;
+ }
+ }
+
+ Color result = Candidate(lab, goLighter ? hi : lo);
+ return result.ContrastRatio(background) >= required ? result : this;
+
+ Color Candidate(Oklab source, double lightness) =>
+ FromOklab(new Oklab(lightness, source.A, source.B), alpha).Clamp();
+ }
+
+ /// Computes the perceptual (Oklab Euclidean) distance to another color.
+ /// The other color.
+ /// The Oklab distance.
+ public double DistanceTo(Color other)
+ {
+ Oklab a = ToOklab();
+ Oklab b = other.ToOklab();
+ double dl = a.L - b.L;
+ double da = a.A - b.A;
+ double db = a.B - b.B;
+ return Math.Sqrt((dl * dl) + (da * da) + (db * db));
+ }
+
+ /// Mixes this color with another in Oklab space (perceptually uniform).
+ /// The other color.
+ /// The interpolation factor, 0 = this, 1 = other.
+ /// The mixed color.
+ public Color MixOklab(Color other, double t)
+ {
+ Oklab a = ToOklab();
+ Oklab b = other.ToOklab();
+ double inv = 1.0 - t;
+ Oklab mixed = new(
+ (a.L * inv) + (b.L * t),
+ (a.A * inv) + (b.A * t),
+ (a.B * inv) + (b.B * t));
+ return FromOklab(mixed, (A * inv) + (other.A * t));
+ }
+
+ /// Linearly interpolates this color with another in linear-RGB space.
+ /// The other color.
+ /// The interpolation factor, 0 = this, 1 = other.
+ /// The interpolated color.
+ public Color Lerp(Color other, double t)
+ {
+ double inv = 1.0 - t;
+ return new Color(
+ (R * inv) + (other.R * t),
+ (G * inv) + (other.G * t),
+ (B * inv) + (other.B * t),
+ (A * inv) + (other.A * t));
+ }
+
+ /// Builds a perceptually-uniform (Oklab) gradient from this color to another.
+ /// The end color.
+ /// The number of colors to produce (at least 2).
+ /// The gradient, inclusive of both endpoints.
+ /// Thrown when is less than 2.
+ public IReadOnlyList Gradient(Color to, int steps)
+ {
+ if (steps < 2)
+ {
+ throw new ArgumentException("A gradient needs at least 2 steps.", nameof(steps));
+ }
+
+ Color[] result = new Color[steps];
+ result[0] = this;
+ result[steps - 1] = to;
+ for (int i = 1; i < steps - 1; i++)
+ {
+ result[i] = MixOklab(to, i / (double)(steps - 1));
+ }
+
+ return result;
+ }
+}
diff --git a/Semantics.Color/Color.cs b/Semantics.Color/Color.cs
new file mode 100644
index 0000000..2f7e418
--- /dev/null
+++ b/Semantics.Color/Color.cs
@@ -0,0 +1,46 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System.Numerics;
+
+///
+/// A color stored as linear (not gamma-encoded) RGB plus straight alpha, each in the range 0..1.
+/// This is the canonical color type; conversions to and from other color spaces live in the
+/// Color.Conversions partial, and color-science operations in Color.Operations.
+///
+/// Linear red channel.
+/// Linear green channel.
+/// Linear blue channel.
+/// Straight (non-premultiplied) alpha.
+public readonly partial record struct Color(double R, double G, double B, double A)
+{
+ /// Creates a color from linear RGB channels, defaulting alpha to fully opaque.
+ /// Linear red channel.
+ /// Linear green channel.
+ /// Linear blue channel.
+ /// Straight alpha (default 1.0).
+ /// A linear-RGB color.
+ public static Color FromLinear(double r, double g, double b, double a = 1.0) => new(r, g, b, a);
+
+ /// Returns a copy of this color with a replaced alpha.
+ /// The new straight alpha.
+ /// A color with the same RGB and the given alpha.
+ public Color WithAlpha(double a) => new(R, G, B, a);
+
+ /// Returns a copy with every channel clamped to the 0..1 range.
+ /// A gamut- and alpha-clamped color.
+ public Color Clamp() => new(Clamp01(R), Clamp01(G), Clamp01(B), Clamp01(A));
+
+ /// Converts to a linear-RGBA (float).
+ /// A float vector of the linear channels.
+ public Vector4 ToLinearVector4() => new((float)R, (float)G, (float)B, (float)A);
+
+ /// Converts to a linear-RGB (float), dropping alpha.
+ /// A float vector of the linear RGB channels.
+ public Vector3 ToLinearVector3() => new((float)R, (float)G, (float)B);
+
+ internal static double Clamp01(double value) => value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
+}
diff --git a/Semantics.Color/Hsl.cs b/Semantics.Color/Hsl.cs
new file mode 100644
index 0000000..f2ccb4e
--- /dev/null
+++ b/Semantics.Color/Hsl.cs
@@ -0,0 +1,111 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in HSL (hue, saturation, lightness), defined over the gamma-encoded sRGB channels.
+/// Hue is in degrees, 0..360; saturation and lightness are 0..1.
+///
+/// Hue angle in degrees, 0..360.
+/// Saturation, 0..1.
+/// Lightness, 0..1.
+public readonly record struct Hsl(double H, double S, double L)
+{
+ /// Converts a gamma-encoded color to HSL.
+ /// The sRGB color.
+ /// The HSL equivalent.
+ public static Hsl FromSrgb(Srgb srgb)
+ {
+ double max = Math.Max(srgb.R, Math.Max(srgb.G, srgb.B));
+ double min = Math.Min(srgb.R, Math.Min(srgb.G, srgb.B));
+ double l = (max + min) / 2.0;
+ double h = 0.0;
+ double s = 0.0;
+
+ if (max > min)
+ {
+ double d = max - min;
+ s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min);
+ h = HueDegrees(srgb, max, d);
+ }
+
+ return new Hsl(h, s, l);
+ }
+
+ /// Converts this HSL color to a gamma-encoded .
+ /// The sRGB equivalent.
+ public Srgb ToSrgb()
+ {
+ double h = NormalizeHue(H) / 360.0;
+ if (S <= 0.0)
+ {
+ return new Srgb(L, L, L);
+ }
+
+ double q = L < 0.5 ? L * (1.0 + S) : L + S - (L * S);
+ double p = (2.0 * L) - q;
+ return new Srgb(
+ HueToChannel(p, q, h + (1.0 / 3.0)),
+ HueToChannel(p, q, h),
+ HueToChannel(p, q, h - (1.0 / 3.0)));
+ }
+
+ internal static double NormalizeHue(double h)
+ {
+ double r = h % 360.0;
+ return r < 0.0 ? r + 360.0 : r;
+ }
+
+ internal static double HueDegrees(Srgb srgb, double max, double d)
+ {
+ double h;
+ if (max == srgb.R)
+ {
+ h = ((srgb.G - srgb.B) / d) + (srgb.G < srgb.B ? 6.0 : 0.0);
+ }
+ else if (max == srgb.G)
+ {
+ h = ((srgb.B - srgb.R) / d) + 2.0;
+ }
+ else
+ {
+ h = ((srgb.R - srgb.G) / d) + 4.0;
+ }
+
+ return h * 60.0;
+ }
+
+ private static double HueToChannel(double p, double q, double t)
+ {
+ if (t < 0.0)
+ {
+ t += 1.0;
+ }
+
+ if (t > 1.0)
+ {
+ t -= 1.0;
+ }
+
+ if (t < 1.0 / 6.0)
+ {
+ return p + ((q - p) * 6.0 * t);
+ }
+
+ if (t < 1.0 / 2.0)
+ {
+ return q;
+ }
+
+ if (t < 2.0 / 3.0)
+ {
+ return p + ((q - p) * ((2.0 / 3.0) - t) * 6.0);
+ }
+
+ return p;
+ }
+}
diff --git a/Semantics.Color/Hsv.cs b/Semantics.Color/Hsv.cs
new file mode 100644
index 0000000..76d6402
--- /dev/null
+++ b/Semantics.Color/Hsv.cs
@@ -0,0 +1,52 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in HSV (hue, saturation, value), defined over the gamma-encoded sRGB channels.
+/// Hue is in degrees, 0..360; saturation and value are 0..1.
+///
+/// Hue angle in degrees, 0..360.
+/// Saturation, 0..1.
+/// Value (brightness), 0..1.
+public readonly record struct Hsv(double H, double S, double V)
+{
+ /// Converts a gamma-encoded color to HSV.
+ /// The sRGB color.
+ /// The HSV equivalent.
+ public static Hsv FromSrgb(Srgb srgb)
+ {
+ double max = Math.Max(srgb.R, Math.Max(srgb.G, srgb.B));
+ double min = Math.Min(srgb.R, Math.Min(srgb.G, srgb.B));
+ double d = max - min;
+ double h = d > 0.0 ? Hsl.HueDegrees(srgb, max, d) : 0.0;
+ double s = max > 0.0 ? d / max : 0.0;
+ return new Hsv(h, s, max);
+ }
+
+ /// Converts this HSV color to a gamma-encoded .
+ /// The sRGB equivalent.
+ public Srgb ToSrgb()
+ {
+ double h = Hsl.NormalizeHue(H) / 60.0;
+ double c = V * S;
+ double x = c * (1.0 - Math.Abs((h % 2.0) - 1.0));
+ double m = V - c;
+
+ (double r, double g, double b) = h switch
+ {
+ < 1.0 => (c, x, 0.0),
+ < 2.0 => (x, c, 0.0),
+ < 3.0 => (0.0, c, x),
+ < 4.0 => (0.0, x, c),
+ < 5.0 => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+
+ return new Srgb(r + m, g + m, b + m);
+ }
+}
diff --git a/Semantics.Color/NamedColors.cs b/Semantics.Color/NamedColors.cs
new file mode 100644
index 0000000..ddf01f4
--- /dev/null
+++ b/Semantics.Color/NamedColors.cs
@@ -0,0 +1,79 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+using System.Collections.Generic;
+
+/// A small table of common named colors (CSS/X11 subset), as linear .
+public static class NamedColors
+{
+ /// Opaque black (#000000).
+ public static Color Black => Color.FromHex("#000000");
+
+ /// Opaque white (#FFFFFF).
+ public static Color White => Color.FromHex("#FFFFFF");
+
+ /// Opaque red (#FF0000).
+ public static Color Red => Color.FromHex("#FF0000");
+
+ /// Opaque green (#00FF00).
+ public static Color Green => Color.FromHex("#00FF00");
+
+ /// Opaque blue (#0000FF).
+ public static Color Blue => Color.FromHex("#0000FF");
+
+ /// Opaque yellow (#FFFF00).
+ public static Color Yellow => Color.FromHex("#FFFF00");
+
+ /// Opaque cyan (#00FFFF).
+ public static Color Cyan => Color.FromHex("#00FFFF");
+
+ /// Opaque magenta (#FF00FF).
+ public static Color Magenta => Color.FromHex("#FF00FF");
+
+ /// Opaque gray (#808080).
+ public static Color Gray => Color.FromHex("#808080");
+
+ /// Opaque orange (#FFA500).
+ public static Color Orange => Color.FromHex("#FFA500");
+
+ /// Opaque purple (#800080).
+ public static Color Purple => Color.FromHex("#800080");
+
+ /// Fully transparent black (#00000000).
+ public static Color Transparent => Color.FromHex("#00000000");
+
+ private static readonly Dictionary Table = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["black"] = Black,
+ ["white"] = White,
+ ["red"] = Red,
+ ["green"] = Green,
+ ["blue"] = Blue,
+ ["yellow"] = Yellow,
+ ["cyan"] = Cyan,
+ ["magenta"] = Magenta,
+ ["gray"] = Gray,
+ ["grey"] = Gray,
+ ["orange"] = Orange,
+ ["purple"] = Purple,
+ ["transparent"] = Transparent,
+ };
+
+ /// Gets all named colors keyed by name (case-insensitive lookup).
+ public static IReadOnlyDictionary All => Table;
+
+ /// Looks up a named color by name, case-insensitively.
+ /// The color name.
+ /// The resolved color, if found.
+ /// True when the name is known.
+ /// Thrown when is null.
+ public static bool TryGet(string name, out Color color)
+ {
+ Ensure.NotNull(name);
+ return Table.TryGetValue(name, out color);
+ }
+}
diff --git a/Semantics.Color/Oklab.cs b/Semantics.Color/Oklab.cs
new file mode 100644
index 0000000..d960ce0
--- /dev/null
+++ b/Semantics.Color/Oklab.cs
@@ -0,0 +1,82 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the Oklab perceptual color space (Björn Ottosson, 2020), derived from linear RGB.
+///
+/// Perceived lightness.
+/// Green–red axis (negative green, positive red/magenta).
+/// Blue–yellow axis (negative blue, positive yellow).
+public readonly record struct Oklab(double L, double A, double B)
+{
+ /// Converts a linear to Oklab.
+ /// The linear color.
+ /// The Oklab equivalent.
+ public static Oklab FromColor(Color color)
+ {
+ double l = (0.4122214708 * color.R) + (0.5363325363 * color.G) + (0.0514459929 * color.B);
+ double m = (0.2119034982 * color.R) + (0.6806995451 * color.G) + (0.1073969566 * color.B);
+ double s = (0.0883024619 * color.R) + (0.2817188376 * color.G) + (0.6299787005 * color.B);
+
+ double l_ = Cbrt(l);
+ double m_ = Cbrt(m);
+ double s_ = Cbrt(s);
+
+ return new Oklab(
+ (0.2104542553 * l_) + (0.7936177850 * m_) - (0.0040720468 * s_),
+ (1.9779984951 * l_) - (2.4285922050 * m_) + (0.4505937099 * s_),
+ (0.0259040371 * l_) + (0.7827717662 * m_) - (0.8086757660 * s_));
+ }
+
+ /// Converts this Oklab color to a linear .
+ /// Straight alpha for the result (default 1.0).
+ /// The linear-RGB equivalent.
+ public Color ToColor(double a = 1.0)
+ {
+ double l_ = L + (0.3963377774 * A) + (0.2158037573 * B);
+ double m_ = L - (0.1055613458 * A) - (0.0638541728 * B);
+ double s_ = L - (0.0894841775 * A) - (1.2914855480 * B);
+
+ double l = l_ * l_ * l_;
+ double m = m_ * m_ * m_;
+ double s = s_ * s_ * s_;
+
+ return new Color(
+ (+4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s),
+ (-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s),
+ (-0.0041960863 * l) - (0.7034186147 * m) + (1.7076147010 * s),
+ a);
+ }
+
+ /// Converts this Oklab color to its polar form.
+ /// The Oklch equivalent.
+ public Oklch ToOklch()
+ {
+ double c = Math.Sqrt((A * A) + (B * B));
+ double h = Math.Atan2(B, A) * (180.0 / Math.PI);
+ if (h < 0.0)
+ {
+ h += 360.0;
+ }
+
+ return new Oklch(L, c, h);
+ }
+
+ // netstandard2.0 lacks Math.Cbrt; one Newton-Raphson refinement after a
+ // sign-aware Pow gives a correctly-rounded result on all target frameworks.
+ private static double Cbrt(double value)
+ {
+ if (value == 0.0)
+ {
+ return 0.0;
+ }
+
+ double x = Math.Sign(value) * Math.Pow(Math.Abs(value), 1.0 / 3.0);
+ return ((2.0 * x) + (value / (x * x))) / 3.0;
+ }
+}
diff --git a/Semantics.Color/Oklch.cs b/Semantics.Color/Oklch.cs
new file mode 100644
index 0000000..bcfb43b
--- /dev/null
+++ b/Semantics.Color/Oklch.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the polar (lightness, chroma, hue) form of Oklab. Hue is in degrees, 0..360.
+///
+/// Perceived lightness.
+/// Chroma (colourfulness).
+/// Hue angle in degrees, 0..360.
+public readonly record struct Oklch(double L, double C, double H)
+{
+ /// Converts this polar color back to Cartesian .
+ /// The Oklab equivalent.
+ public Oklab ToOklab()
+ {
+ double hRad = H * (Math.PI / 180.0);
+ return new Oklab(L, C * Math.Cos(hRad), C * Math.Sin(hRad));
+ }
+}
diff --git a/Semantics.Color/Semantics.Color.csproj b/Semantics.Color/Semantics.Color.csproj
new file mode 100644
index 0000000..3b4c90a
--- /dev/null
+++ b/Semantics.Color/Semantics.Color.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+ net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1
+
+
+
+
+
+
+
+
diff --git a/Semantics.Color/Srgb.cs b/Semantics.Color/Srgb.cs
new file mode 100644
index 0000000..3a5ba63
--- /dev/null
+++ b/Semantics.Color/Srgb.cs
@@ -0,0 +1,34 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the gamma-encoded sRGB space, each channel 0..1. This is the only color space that
+/// crosses the gamma boundary to and from the linear .
+///
+/// Gamma-encoded red channel.
+/// Gamma-encoded green channel.
+/// Gamma-encoded blue channel.
+public readonly record struct Srgb(double R, double G, double B)
+{
+ /// Converts this sRGB color to a linear .
+ /// Straight alpha for the resulting color (default 1.0).
+ /// The linear-RGB equivalent.
+ public Color ToLinear(double a = 1.0) => new(DecodeChannel(R), DecodeChannel(G), DecodeChannel(B), a);
+
+ /// Converts a linear to gamma-encoded sRGB (alpha dropped).
+ /// The linear color.
+ /// The sRGB equivalent.
+ public static Srgb FromLinear(Color color) =>
+ new(EncodeChannel(color.R), EncodeChannel(color.G), EncodeChannel(color.B));
+
+ private static double DecodeChannel(double s) =>
+ s <= 0.04045 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4);
+
+ private static double EncodeChannel(double linear) =>
+ linear <= 0.0031308 ? 12.92 * linear : (1.055 * Math.Pow(linear, 1.0 / 2.4)) - 0.055;
+}
diff --git a/Semantics.Test/Colors/AccessibilityTests.cs b/Semantics.Test/Colors/AccessibilityTests.cs
new file mode 100644
index 0000000..dc7c375
--- /dev/null
+++ b/Semantics.Test/Colors/AccessibilityTests.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class AccessibilityTests
+{
+ [TestMethod]
+ public void BlackOnWhite_HasMaximumContrast()
+ {
+ Color black = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color white = Color.FromSrgb(1.0, 1.0, 1.0);
+ Assert.AreEqual(21.0, black.ContrastRatio(white), 1e-2);
+ }
+
+ [TestMethod]
+ public void SameColor_HasUnitContrast()
+ {
+ Color gray = Color.FromSrgb(0.5, 0.5, 0.5);
+ Assert.AreEqual(1.0, gray.ContrastRatio(gray), 1e-9);
+ }
+
+ [TestMethod]
+ public void BlackOnWhite_RatesAaa()
+ {
+ Color black = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color white = Color.FromSrgb(1.0, 1.0, 1.0);
+ Assert.AreEqual(AccessibilityLevel.AAA, black.AccessibilityLevelAgainst(white));
+ }
+
+ [TestMethod]
+ public void AdjustForContrast_ReachesRequestedLevel()
+ {
+ Color background = Color.FromSrgb(1.0, 1.0, 1.0);
+ Color faint = Color.FromSrgb(0.85, 0.85, 0.2);
+ Color adjusted = faint.AdjustForContrast(background, AccessibilityLevel.AA);
+ Assert.IsTrue(
+ adjusted.AccessibilityLevelAgainst(background) >= AccessibilityLevel.AA,
+ $"contrast was {adjusted.ContrastRatio(background)}");
+ }
+}
diff --git a/Semantics.Test/Colors/ColorCoreTests.cs b/Semantics.Test/Colors/ColorCoreTests.cs
new file mode 100644
index 0000000..93adb14
--- /dev/null
+++ b/Semantics.Test/Colors/ColorCoreTests.cs
@@ -0,0 +1,49 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System.Numerics;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class ColorCoreTests
+{
+ [TestMethod]
+ public void FromLinear_StoresChannelsAndDefaultsAlphaToOne()
+ {
+ Color c = Color.FromLinear(0.1, 0.2, 0.3);
+ Assert.AreEqual(0.1, c.R, 1e-12);
+ Assert.AreEqual(0.2, c.G, 1e-12);
+ Assert.AreEqual(0.3, c.B, 1e-12);
+ Assert.AreEqual(1.0, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void WithAlpha_ReplacesAlphaOnly()
+ {
+ Color c = Color.FromLinear(0.1, 0.2, 0.3, 1.0).WithAlpha(0.5);
+ Assert.AreEqual(0.1, c.R, 1e-12);
+ Assert.AreEqual(0.5, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void Clamp_BringsChannelsIntoUnitRange()
+ {
+ Color c = Color.FromLinear(-0.5, 0.5, 1.5, 2.0).Clamp();
+ Assert.AreEqual(0.0, c.R, 1e-12);
+ Assert.AreEqual(0.5, c.G, 1e-12);
+ Assert.AreEqual(1.0, c.B, 1e-12);
+ Assert.AreEqual(1.0, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void ToLinearVector4_ReturnsFloatChannels()
+ {
+ Vector4 v = Color.FromLinear(0.1, 0.2, 0.3, 0.4).ToLinearVector4();
+ Assert.AreEqual(0.1f, v.X, 1e-6f);
+ Assert.AreEqual(0.4f, v.W, 1e-6f);
+ }
+}
diff --git a/Semantics.Test/Colors/ColorOperationTests.cs b/Semantics.Test/Colors/ColorOperationTests.cs
new file mode 100644
index 0000000..4a98b76
--- /dev/null
+++ b/Semantics.Test/Colors/ColorOperationTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System;
+using System.Collections.Generic;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class ColorOperationTests
+{
+ [TestMethod]
+ public void DistanceTo_Self_IsZero()
+ {
+ Color c = Color.FromSrgb(0.3, 0.6, 0.9);
+ Assert.AreEqual(0.0, c.DistanceTo(c), 1e-12);
+ }
+
+ [TestMethod]
+ public void DistanceTo_IsSymmetric()
+ {
+ Color a = Color.FromSrgb(0.1, 0.2, 0.3);
+ Color b = Color.FromSrgb(0.9, 0.8, 0.7);
+ Assert.AreEqual(a.DistanceTo(b), b.DistanceTo(a), 1e-12);
+ }
+
+ [TestMethod]
+ public void Lerp_AtEndpoints_ReturnsEndpoints()
+ {
+ Color a = Color.FromLinear(0.0, 0.0, 0.0);
+ Color b = Color.FromLinear(1.0, 1.0, 1.0);
+ Assert.AreEqual(0.0, a.Lerp(b, 0.0).R, 1e-12);
+ Assert.AreEqual(1.0, a.Lerp(b, 1.0).R, 1e-12);
+ Assert.AreEqual(0.5, a.Lerp(b, 0.5).R, 1e-12);
+ }
+
+ [TestMethod]
+ public void MixOklab_Halfway_IsBetweenEndpoints()
+ {
+ Color a = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color b = Color.FromSrgb(1.0, 1.0, 1.0);
+ Color mid = a.MixOklab(b, 0.5);
+ Assert.IsTrue(mid.R > a.R && mid.R < b.R, $"mid.R was {mid.R}");
+ }
+
+ [TestMethod]
+ public void Gradient_ReturnsRequestedStepsWithMatchingEndpoints()
+ {
+ Color a = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color b = Color.FromSrgb(1.0, 1.0, 1.0);
+ IReadOnlyList gradient = a.Gradient(b, 5);
+ Assert.AreEqual(5, gradient.Count);
+ Assert.AreEqual(a.R, gradient[0].R, 1e-9);
+ Assert.AreEqual(b.R, gradient[4].R, 1e-9);
+ }
+
+ [TestMethod]
+ public void Gradient_TooFewSteps_Throws() =>
+ Assert.ThrowsExactly(() => Color.FromSrgb(0, 0, 0).Gradient(Color.FromSrgb(1, 1, 1), 1));
+}
diff --git a/Semantics.Test/Colors/GammaRegressionTests.cs b/Semantics.Test/Colors/GammaRegressionTests.cs
new file mode 100644
index 0000000..fbe3d6a
--- /dev/null
+++ b/Semantics.Test/Colors/GammaRegressionTests.cs
@@ -0,0 +1,33 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class GammaRegressionTests
+{
+ [TestMethod]
+ public void HexDisplayIsStable_AcrossLinearRoundTrip()
+ {
+ // Proves the fix is display-safe: hex -> linear Color -> hex returns the same hex.
+ string[] samples = ["#000000", "#FFFFFF", "#3A7BD5", "#7F7F7F", "#FFA500"];
+ foreach (string hex in samples)
+ {
+ Assert.AreEqual(hex, Color.FromHex(hex).ToHex());
+ }
+ }
+
+ [TestMethod]
+ public void OklabFromLinear_DiffersFromNaiveSrgbAsLinear()
+ {
+ // The old bug fed sRGB values straight into the Oklab matrix. Confirm the correct (linear)
+ // computation produces a different lightness for mid-gray.
+ Color correct = Color.FromHex("#7F7F7F");
+ double correctL = correct.ToOklab().L;
+ double naiveL = Color.FromLinear(127.0 / 255.0, 127.0 / 255.0, 127.0 / 255.0).ToOklab().L;
+ Assert.AreNotEqual(naiveL, correctL, 1e-3);
+ }
+}
diff --git a/Semantics.Test/Colors/HexConversionTests.cs b/Semantics.Test/Colors/HexConversionTests.cs
new file mode 100644
index 0000000..fe2712f
--- /dev/null
+++ b/Semantics.Test/Colors/HexConversionTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class HexConversionTests
+{
+ [TestMethod]
+ public void FromHex_ParsesSixDigitAsOpaqueSrgb()
+ {
+ Color red = Color.FromHex("#FF0000");
+ Assert.AreEqual(1.0, red.A, 1e-12);
+ (byte r, byte g, byte b, byte a) = red.ToBytes();
+ Assert.AreEqual((byte)255, r);
+ Assert.AreEqual((byte)0, g);
+ Assert.AreEqual((byte)0, b);
+ Assert.AreEqual((byte)255, a);
+ }
+
+ [TestMethod]
+ public void FromHex_ParsesEightDigitAlpha()
+ {
+ Color half = Color.FromHex("#00000080");
+ Assert.AreEqual(128.0 / 255.0, half.A, 1e-6);
+ }
+
+ [TestMethod]
+ public void FromHex_ParsesThreeDigitShorthand()
+ {
+ Color a = Color.FromHex("#F00");
+ Color b = Color.FromHex("#FF0000");
+ Assert.AreEqual(b.R, a.R, 1e-12);
+ }
+
+ [TestMethod]
+ public void FromHex_AcceptsNoLeadingHash()
+ {
+ (byte r, _, _, _) = Color.FromHex("00FF00").ToBytes();
+ Assert.AreEqual((byte)0, r);
+ }
+
+ [TestMethod]
+ public void HexRoundTrip_IsStable()
+ {
+ Assert.AreEqual("#3A7BD5", Color.FromHex("#3A7BD5").ToHex());
+ }
+
+ [TestMethod]
+ public void ToHex_EmitsAlphaWhenNotOpaque()
+ {
+ Assert.AreEqual("#3A7BD580", Color.FromHex("#3A7BD580").ToHex());
+ }
+
+ [TestMethod]
+ public void FromHex_InvalidLength_Throws() =>
+ Assert.ThrowsExactly(() => Color.FromHex("#12345"));
+}
diff --git a/Semantics.Test/Colors/HslHsvConversionTests.cs b/Semantics.Test/Colors/HslHsvConversionTests.cs
new file mode 100644
index 0000000..2708782
--- /dev/null
+++ b/Semantics.Test/Colors/HslHsvConversionTests.cs
@@ -0,0 +1,49 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class HslHsvConversionTests
+{
+ [TestMethod]
+ public void Red_HasZeroHueFullSaturation()
+ {
+ Hsl hsl = Color.FromSrgb(1.0, 0.0, 0.0).ToHsl();
+ Assert.AreEqual(0.0, hsl.H, 1e-6);
+ Assert.AreEqual(1.0, hsl.S, 1e-6);
+ Assert.AreEqual(0.5, hsl.L, 1e-6);
+ }
+
+ [TestMethod]
+ public void Green_HasHue120()
+ {
+ Hsv hsv = Color.FromSrgb(0.0, 1.0, 0.0).ToHsv();
+ Assert.AreEqual(120.0, hsv.H, 1e-4);
+ Assert.AreEqual(1.0, hsv.S, 1e-6);
+ Assert.AreEqual(1.0, hsv.V, 1e-6);
+ }
+
+ [TestMethod]
+ public void HslRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromHsl(original.ToHsl());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+
+ [TestMethod]
+ public void HsvRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.7, 0.3, 0.55);
+ Color roundTripped = Color.FromHsv(original.ToHsv());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+}
diff --git a/Semantics.Test/Colors/NamedColorsTests.cs b/Semantics.Test/Colors/NamedColorsTests.cs
new file mode 100644
index 0000000..af1d0cd
--- /dev/null
+++ b/Semantics.Test/Colors/NamedColorsTests.cs
@@ -0,0 +1,31 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class NamedColorsTests
+{
+ [TestMethod]
+ public void Red_MatchesPureRedHex() =>
+ Assert.AreEqual("#FF0000", NamedColors.Red.ToHex());
+
+ [TestMethod]
+ public void Transparent_HasZeroAlpha() =>
+ Assert.AreEqual(0.0, NamedColors.Transparent.A, 1e-12);
+
+ [TestMethod]
+ public void TryGet_IsCaseInsensitive()
+ {
+ bool found = NamedColors.TryGet("ReD", out Color color);
+ Assert.IsTrue(found);
+ Assert.AreEqual("#FF0000", color.ToHex());
+ }
+
+ [TestMethod]
+ public void TryGet_UnknownName_ReturnsFalse() =>
+ Assert.IsFalse(NamedColors.TryGet("notacolor", out _));
+}
diff --git a/Semantics.Test/Colors/OklabConversionTests.cs b/Semantics.Test/Colors/OklabConversionTests.cs
new file mode 100644
index 0000000..4931b65
--- /dev/null
+++ b/Semantics.Test/Colors/OklabConversionTests.cs
@@ -0,0 +1,51 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class OklabConversionTests
+{
+ [TestMethod]
+ public void White_HasLightnessOneAndNoChroma()
+ {
+ // Reference (Ottosson): linear white -> Oklab L=1, a=0, b=0.
+ Oklab lab = Color.FromLinear(1.0, 1.0, 1.0).ToOklab();
+ Assert.AreEqual(1.0, lab.L, 1e-4);
+ Assert.AreEqual(0.0, lab.A, 1e-4);
+ Assert.AreEqual(0.0, lab.B, 1e-4);
+ }
+
+ [TestMethod]
+ public void OklabRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromOklab(original.ToOklab());
+ // Tolerance is 1e-6: the 10-significant-digit Ottosson matrix constants limit
+ // round-trip precision to ~1e-7 for colours with small linear channel values.
+ Assert.AreEqual(original.R, roundTripped.R, 1e-6);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-6);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-6);
+ }
+
+ [TestMethod]
+ public void Oklch_RedHasPositiveChroma()
+ {
+ Oklch lch = Color.FromSrgb(1.0, 0.0, 0.0).ToOklch();
+ Assert.IsTrue(lch.C > 0.1, $"expected chroma > 0.1 but was {lch.C}");
+ }
+
+ [TestMethod]
+ public void OklchRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromOklch(original.ToOklch());
+ // Tolerance is 1e-6: same constraint as OklabRoundTrip (Oklch passes through Oklab).
+ Assert.AreEqual(original.R, roundTripped.R, 1e-6);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-6);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-6);
+ }
+}
diff --git a/Semantics.Test/Colors/SrgbConversionTests.cs b/Semantics.Test/Colors/SrgbConversionTests.cs
new file mode 100644
index 0000000..7d0e9a9
--- /dev/null
+++ b/Semantics.Test/Colors/SrgbConversionTests.cs
@@ -0,0 +1,49 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System.Numerics;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class SrgbConversionTests
+{
+ [TestMethod]
+ public void SrgbToLinearToSrgb_RoundTripsToIdentity()
+ {
+ for (int i = 0; i <= 100; i++)
+ {
+ double channel = i / 100.0;
+ Srgb original = new(channel, channel, channel);
+ Srgb roundTripped = original.ToLinear().ToSrgb();
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ }
+ }
+
+ [TestMethod]
+ public void Srgb_MidGray_DecodesToSmallerLinearValue()
+ {
+ // sRGB 0.5 is perceptual mid-gray; its linear value is ~0.214 (proves gamma decode happens).
+ Color c = Color.FromSrgb(0.5, 0.5, 0.5);
+ Assert.AreEqual(0.21404, c.R, 1e-4);
+ }
+
+ [TestMethod]
+ public void Srgb_EndpointsMapToLinearEndpoints()
+ {
+ Assert.AreEqual(0.0, Color.FromSrgb(0.0, 0.0, 0.0).R, 1e-12);
+ Assert.AreEqual(1.0, Color.FromSrgb(1.0, 1.0, 1.0).R, 1e-12);
+ }
+
+ [TestMethod]
+ public void ToSrgbVector4_ReturnsGammaEncodedChannels()
+ {
+ Color c = Color.FromSrgb(0.5, 0.5, 0.5, 1.0);
+ Vector4 v = c.ToSrgbVector4();
+ Assert.AreEqual(0.5f, v.X, 1e-4f);
+ Assert.AreEqual(1.0f, v.W, 1e-6f);
+ }
+}
diff --git a/Semantics.Test/Semantics.Test.csproj b/Semantics.Test/Semantics.Test.csproj
index 959f5e0..97efded 100644
--- a/Semantics.Test/Semantics.Test.csproj
+++ b/Semantics.Test/Semantics.Test.csproj
@@ -17,6 +17,7 @@
+
diff --git a/Semantics.sln b/Semantics.sln
index bcb7ccc..f90791d 100644
--- a/Semantics.sln
+++ b/Semantics.sln
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Quantities.Decima
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Music", "Semantics.Music\Semantics.Music.csproj", "{B608B8EE-644C-48A5-8846-89AA448AF1F7}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Color", "Semantics.Color\Semantics.Color.csproj", "{D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -139,6 +141,18 @@ Global
{B608B8EE-644C-48A5-8846-89AA448AF1F7}.Release|x64.Build.0 = Release|Any CPU
{B608B8EE-644C-48A5-8846-89AA448AF1F7}.Release|x86.ActiveCfg = Release|Any CPU
{B608B8EE-644C-48A5-8846-89AA448AF1F7}.Release|x86.Build.0 = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x64.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x86.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x64.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x64.Build.0 = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/docs/roadmap-semantic-domains.md b/docs/roadmap-semantic-domains.md
new file mode 100644
index 0000000..4d32315
--- /dev/null
+++ b/docs/roadmap-semantic-domains.md
@@ -0,0 +1,235 @@
+# Roadmap: new semantic domains
+
+Status: proposed (2026-06-29). This is a planning document, not a commitment. It sequences the
+candidate domains discussed for `ktsu.Semantics` and records the design decisions each one needs
+before implementation starts. Read alongside `docs/strategy-unified-vector-quantities.md` (the
+quantity model) and `CLAUDE.md` (project layout).
+
+## Where the library is today
+
+Four semantic domains ship:
+
+| Domain | Project | Maturity |
+|---|---|---|
+| Strings | `Semantics.Strings` | Framework + validation attributes only — **no concrete domain string types ship** |
+| Paths | `Semantics.Paths` | Complete (12 interfaces, 4 concrete types, 12 validation attributes) |
+| Physical quantities | `Semantics.Quantities` (+ generators) | Deep: 72 dimensions, ~189 units, 73 relationships, 8 log scales, constants |
+| Music | `Semantics.Music` | Hand-written: pitch/interval/scale/key/chord/rhythm; **no aggregate (Score) layer** |
+
+The biggest leverage is therefore *filling in* Strings and Music as much as adding new domains.
+
+## Guiding principles
+
+1. **Each domain is its own package** (`Semantics.`), so consumers pull only what they need
+ and the multi-target matrix stays tractable. Strings/Paths target `netstandard2.0`+; anything
+ that needs `INumber` is `net8.0`+.
+2. **Reuse the quantity system where the domain is genuinely dimensional** (Geo distance → `Length`,
+ Data-rate → a new dimension). Keep non-dimensional domains (Money, Color channels) separate from
+ the `INumber` vector model — forcing them in distorts both.
+3. **Consolidate before you create.** Color already exists twice in the ecosystem; the Color work is
+ primarily a migration, not a green-field build.
+4. **Validation attributes are an existing asset.** Concrete strings should lean on the casing /
+ format / regex attributes already in `Semantics.Strings` rather than re-deriving parsing.
+
+---
+
+## Phase 0 — Concrete semantic strings (`Semantics.Strings.Web` / `.Identifiers`)
+
+**Why first:** lowest effort, highest visible payoff. The framework is idle without batteries-included
+types, and these are pure compositions of attributes that already exist.
+
+**Scope**
+- Web/contact: `EmailAddress`, `Url`/`HttpUrl`, `Hostname`, `DomainName`, `IpV4`, `IpV6`,
+ `MacAddress`, `PhoneNumber` (E.164), `Slug`.
+- Identifiers: `Uuid`/`Guid`, `Ulid`, `Base64`/`Base64Url`, `HexString`, `JwtToken`,
+ `CreditCardNumber` (Luhn), `Iban`, `Isbn`, `SemVer`, `HexColor`.
+
+**Decisions to make**
+- One package or split (`.Web` vs `.Identifiers`)? Recommend split — different audiences.
+- `HexColor` overlaps Phase 3 (Color). Define it here as a *string* type; Color consumes/produces it.
+- Which validators need new attributes vs. compose existing ones (Luhn and IBAN check-digits are new).
+
+**Effort:** S. **Depends on:** nothing.
+
+---
+
+## Phase 1 — Music aggregate layer (`Semantics.Music`)
+
+**Why early:** the domain already exists and has momentum; it's missing only the container layer that
+makes it usable for real scores.
+
+**Scope**
+- Containers: `Score`, `Track`/`Part`, `Measure`/`Bar`, `Voice` — ordered `IMusicalEvent` sequences
+ with bar/beat math derived from `TimeSignature` + `Tempo`.
+- `Progression` — sequence of `Chord` with functional analysis over a `Key` (extends existing
+ roman-numeral support).
+- `Tuning`/`Temperament` (equal, just, Pythagorean) so `Pitch.FromFrequency` is no longer hardwired
+ to A440 equal temperament.
+- Notation niceties: `Clef`, `KeySignature`, `Articulation`, `Dynamic` scale (`pp`..`ff`, bridging to
+ existing `Velocity`).
+
+**Decisions to make**
+- Is `Score` immutable (record-style, rebuild on edit) or a mutable builder? Recommend immutable core
+ + a builder, consistent with the rest of the library.
+- Does `Tuning` tie into the quantities `Frequency` type, or stay self-contained? Recommend bridging
+ to `Frequency` since that type already exists.
+
+**Effort:** M. **Depends on:** nothing (extends current Music).
+
+---
+
+## Phase 2 — Color (`Semantics.Color`) — **consolidation, not green-field**
+
+**Why this matters:** color science already exists **twice** in the ecosystem, with overlap and at
+least one latent correctness bug:
+
+- `ktsu.ThemeProvider` has the real implementation: `RgbColor`, `SRgbColor` (a genuine linear/sRGB
+ split), `OklabColor` (+ LCh polar), `PerceptualColor`, and `ColorMath` (RGB↔Oklab, WCAG relative
+ luminance, contrast ratio, `AccessibilityLevel`, accessibility-driven lightness adjustment via
+ binary search, perceptually-uniform gradients).
+- `ktsu.ImGuiStyler` has a *second, weaker* copy: its own `FromHex`, `FromRGB/RGBA`, `FromHSL/HSLA`
+ (a hand-rolled `HueToRGB`), all producing `Hexa.NET.ImGui.ImColor`, and it depends on
+ ThemeProvider's `RgbColor`/`PerceptualColor` on top.
+
+**Latent bug to fix in the move:** `RgbColor.FromHex` parses sRGB hex bytes straight into a struct
+documented as *linear* RGB with no gamma decode, and `ColorMath.RgbToOklab` (whose matrix assumes
+linear input) then consumes it. A rigorous `Semantics.Color` makes the sRGB↔linear boundary explicit
+and the mistake unrepresentable.
+
+**Target architecture**
+- `Semantics.Color` owns **color value types + color science only**:
+ - Spaces: `SRgb`, `LinearRgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`, (stretch: `Lab`, `Xyz`); enforced
+ conversions with the gamma boundary correct.
+ - Operations: hex parse/format, WCAG luminance + contrast ratio, `AccessibilityLevel`, contrast
+ adjustment, perceptual distance, perceptual gradients/lerp, mixing.
+ - Stretch synergy with quantities: spectral `Wavelength → Color`, tie WCAG luminance to the
+ photometry dimensions (`Luminance`/`Illuminance`) that already exist.
+- `ktsu.ThemeProvider` keeps the **semantic theming layer** only (`SemanticMeaning`,
+ `SemanticColorRequest`, `IPaletteMapper`, `SemanticColorMapper`, `ISemanticTheme`, `ThemeRegistry`,
+ the ~40 bundled themes) and takes a dependency on `Semantics.Color`.
+- `ktsu.ImGuiStyler` deletes its bespoke color math and keeps only a thin
+ `Semantics.Color ↔ ImColor` adapter at the ImGui boundary.
+
+**Migration plan (own sub-roadmap)** — detailed in
+`superpowers/specs/2026-06-29-semantics-color-design.md`. Direct migration, **no shims**:
+1. Build & publish `Semantics.Color` (+ `Semantics.Color.ImGui` adapter) with the value types +
+ science ported from `ColorMath`/`OklabColor`/etc., gamma boundary fixed, full tests.
+2. Re-point `ThemeProvider` at `Semantics.Color` (`PerceptualColor → Color`, breaking; major bump).
+3. Re-point `ImGuiStyler` at `Semantics.Color(.ImGui)`; reduce its `Color` class to ImColor adapters.
+
+**Decisions to make** *(these block the build — see open questions)*
+- **Channel storage:** float-only (matches existing code + ImGui), or generic `Color` over
+ `INumber` (consistent with quantities, heavier)? Recommend **float/double, not the `INumber`
+ vector model** — color channels aren't a physics vector and the generic buys little here.
+- **Coordination with the repo owners** of ThemeProvider/ImGuiStyler on the shim/deprecation timeline
+ (cross-repo change).
+- Does `HexColor` (Phase 0 string) become the canonical parse entry point?
+
+**Effort:** L (spans three repos). **Depends on:** Phase 0 only for the optional `HexColor` link.
+
+---
+
+## Phase 3 — Money / Currency (`Semantics.Money`)
+
+**Why:** the canonical "primitive obsession" target and the clearest showcase of the semantic-type
+thesis after strings.
+
+**Scope**
+- `Currency` (ISO 4217), `Money` (decimal amount + currency), arithmetic that *refuses* cross-currency
+ add/subtract at compile or runtime, rounding policies, allocation/splitting without losing pennies,
+ formatting per culture.
+
+**Decisions to make**
+- Explicitly **not** part of the quantity generator — currency isn't convertible by a fixed constant.
+ Document why (mirrors the log-scale "doesn't obey linear arithmetic" carve-out).
+- FX/exchange-rate handling: in-scope as an explicit `ExchangeRate` conversion, or out of scope?
+ Recommend a minimal `ExchangeRate` type, no rate *sourcing*.
+
+**Effort:** M. **Depends on:** nothing.
+
+---
+
+## Phase 4 — Geo (`Semantics.Geo`)
+
+**Why:** strong synergy with the quantity system — it consumes `Length`, `Bearing`/`Heading`
+(already angular dimensions), and `Velocity`.
+
+**Scope**
+- `Latitude`, `Longitude`, `Coordinate` (lat/long pair), `Altitude` (already a `Length` overload),
+ haversine/great-circle distance → `Length`, initial bearing → `Bearing`, `GeoHash`, bounding boxes.
+
+**Decisions to make**
+- Datum/projection scope: WGS84 spherical only (recommend), or pluggable ellipsoid/projection (large)?
+- Reuse `Distance`/`Bearing` quantity types directly as return values (recommend) vs. bespoke types.
+
+**Effort:** M. **Depends on:** Quantities (already present).
+
+---
+
+## Phase 5 — Calendar / Temporal (`Semantics.Calendar`)
+
+**Why:** common primitive-obsession area; distinct from the physics `Time` quantity (duration vs.
+calendar position).
+
+**Scope**
+- `Date`, `TimeOfDay`, `DayOfWeek`, `Month`, `Quarter`, ISO week, `DateRange`/`Interval`, business-day
+ math, recurrence helpers.
+
+**Decisions to make**
+- **Name collision:** the physics `Time` dimension already exists. Package/namespace must disambiguate
+ (recommend `Semantics.Calendar`, never `Semantics.Time`).
+- Build on `DateOnly`/`TimeOnly`/`NodaTime`, or self-contained? Recommend wrapping BCL
+ `DateOnly`/`TimeOnly` (net6+), with care for the netstandard targets.
+
+**Effort:** M. **Depends on:** nothing (but coordinate naming with Quantities).
+
+---
+
+## Phase 6 — Data size & rate (`Semantics.Data`)
+
+**Why:** small, high-utility, and a good test of whether the quantity generator can absorb a new
+dimension cleanly.
+
+**Scope**
+- `DataSize` (bytes) with binary (KiB/MiB) **and** decimal (KB/MB) prefixes, `DataRate` (bit/s),
+ the integral/derivative relationship between them over `Time`.
+
+**Decisions to make**
+- **Generator vs. hand-written:** strongly consider adding `Information` as a dimension in
+ `dimensions.json` (unit = byte/bit) so `DataSize`/`DataRate` fall out of the existing machinery,
+ including the `DataRate = DataSize / Time` relationship. This is the cleanest fit and validates the
+ generator's extensibility.
+
+**Effort:** S–M. **Depends on:** Quantities generator.
+
+---
+
+## Suggested sequence & rationale
+
+```
+Phase 0 Concrete strings ── ship value immediately, no deps
+Phase 1 Music aggregates ── finish a domain already in flight
+Phase 2 Color (consolidation) ── retire duplication + fix gamma bug (cross-repo, start early)
+Phase 3 Money ── flagship new domain
+Phase 4 Geo ── leans on quantities
+Phase 5 Calendar ── careful naming vs. Time
+Phase 6 Data size/rate ── exercise the generator
+```
+
+Phases 0/1 are independent and could run in parallel. Phase 2 is the largest and touches three repos,
+so kick off its design (the channel-storage decision + repo-owner coordination) early even if coding
+lands later.
+
+## Open questions (resolve before committing)
+
+1. ~~**Color channel storage**~~ — **Resolved:** `double` internally, `float` interop helpers; not
+ generic over `INumber`. See `superpowers/specs/2026-06-29-semantics-color-design.md`.
+2. ~~**Color migration ownership & timeline**~~ — **Resolved:** direct migration, **no shims**;
+ ThemeProvider/ImGuiStyler updated in lockstep and shipped immediately in publish order
+ (`Semantics.Color` → ThemeProvider → ImGuiStyler).
+3. **Money in or out of the quantity model** — confirm the deliberate carve-out (recommended: out).
+4. **Data size in the generator** — add `Information` as a dimension vs. hand-write. (Recommendation:
+ generator.)
+5. **String package granularity** — one `Semantics.Strings.*` package or split Web/Identifiers.
+6. **Calendar naming** — lock a namespace that never collides with the physics `Time` dimension.
+```
diff --git a/docs/superpowers/plans/2026-06-29-semantics-color.md b/docs/superpowers/plans/2026-06-29-semantics-color.md
new file mode 100644
index 0000000..f85d98b
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-semantics-color.md
@@ -0,0 +1,1721 @@
+# Semantics.Color Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build `ktsu.Semantics.Color`, a rigorous, dependency-light color-science library (canonical linear-RGB `Color` + space structs + WCAG/Oklab operations) that fixes the sRGB/linear gamma bug present in the existing ThemeProvider/ImGuiStyler color code.
+
+**Architecture:** A canonical `readonly record struct Color(double R, double G, double B, double A)` stores **linear** RGB + alpha and is the type consumers pass around. Lightweight `readonly record struct` space types (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`) are conversion targets/math intermediates. The sRGB↔linear gamma transfer is confined to the `Srgb`↔`Color` boundary, so perceptual math can never run on gamma-encoded values. No UI-framework dependency (the ImGui adapter is a separate package in another repo).
+
+**Tech Stack:** C# / .NET, ktsu.Sdk, multi-target `net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1`, MSTest (test project, net10.0 only), Polyfill (`Ensure`).
+
+**Spec:** `docs/superpowers/specs/2026-06-29-semantics-color-design.md`
+
+## Global Constraints
+
+Every task implicitly includes these (copied verbatim from project conventions / the spec):
+
+- **File header** on every `.cs` file (generator note N/A — these are hand-written):
+ ```csharp
+ // Copyright (c) ktsu.dev
+ // All rights reserved.
+ // Licensed under the MIT license.
+ ```
+- **Tabs** for indentation. **CRLF** line endings.
+- **File-scoped namespace** (`namespace ktsu.Semantics.Color;`), `using` directives **inside** the namespace, no `this.` qualifier, explicit accessibility modifiers, braces on all control flow.
+- **Nullable reference types enabled; warnings as errors** (inherited from ktsu.Sdk).
+- Library namespace: `ktsu.Semantics.Color`. Test namespace: `ktsu.Semantics.Test.Colors` (NOT `...Color` — a `Color` namespace segment would shadow the `Color` type). Test files live in `Semantics.Test/Colors/`.
+- **Channel storage is `double`; interop helpers return `float`.** Not generic over `INumber`.
+- **Canonical `Color` is linear RGB.** Only `Srgb`↔`Color` crosses the gamma boundary.
+- Tests: explicit types (no `var`); float comparisons use `Assert.AreEqual(expected, actual, delta)`.
+- Validation failures throw `ArgumentException` (most specific type available); use `Ensure.NotNull` (Polyfill) for null checks.
+- Build the library: `dotnet build Semantics.Color/Semantics.Color.csproj`.
+- Run a test class: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~"`.
+
+## File Structure
+
+Library (`Semantics.Color/`):
+
+| File | Responsibility |
+|---|---|
+| `Semantics.Color.csproj` | Project file (mirrors `Semantics.Music.csproj`). |
+| `Color.cs` | Canonical `Color` struct: fields, `FromLinear`, `WithAlpha`, clamp, `Vector4`/`Vector3` interop. |
+| `Color.Conversions.cs` | `partial Color`: `Srgb`/hex/bytes/Oklab/Oklch/Hsl/Hsv `From*`/`To*`. |
+| `Color.Operations.cs` | `partial Color`: `RelativeLuminance`, `ContrastRatio`, `AccessibilityLevelAgainst`, `AdjustForContrast`, `DistanceTo`, `MixOklab`, `Lerp`, `Gradient`. |
+| `Srgb.cs` | `Srgb` struct + gamma transfer (`ToLinear`/`FromLinear`). |
+| `Oklab.cs` | `Oklab` struct + linear↔Oklab matrices + `cbrt` helper. |
+| `Oklch.cs` | `Oklch` struct + `Oklab`↔`Oklch` polar conversion. |
+| `Hsl.cs` | `Hsl` struct + sRGB↔HSL. |
+| `Hsv.cs` | `Hsv` struct + sRGB↔HSV. |
+| `AccessibilityLevel.cs` | `AccessibilityLevel` enum. |
+| `NamedColors.cs` | CSS/X11 named-color table. |
+
+Tests (`Semantics.Test/Colors/`): one file per task as noted.
+
+---
+
+### Task 1: Project scaffold + `Color` core
+
+**Files:**
+- Create: `Semantics.Color/Semantics.Color.csproj`
+- Create: `Semantics.Color/Color.cs`
+- Modify: `Semantics.sln` (add project + config rows)
+- Modify: `Semantics.Test/Semantics.Test.csproj` (add ProjectReference)
+- Test: `Semantics.Test/Colors/ColorCoreTests.cs`
+
+**Interfaces:**
+- Produces: `readonly partial record struct Color(double R, double G, double B, double A)` with statics `Color FromLinear(double r, double g, double b, double a = 1.0)`; instance `Color WithAlpha(double a)`, `Color Clamp()`, `Vector4 ToLinearVector4()`, `Vector3 ToLinearVector3()`. (Space-specific conversions and ops are added as `partial` in later tasks.)
+
+- [ ] **Step 1: Create the project file**
+
+Create `Semantics.Color/Semantics.Color.csproj`:
+
+```xml
+
+
+
+
+
+ net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add the project to the solution**
+
+In `Semantics.sln`, add this project declaration immediately after the `Semantics.Music` `EndProject` (line ~23):
+
+```
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Color", "Semantics.Color\Semantics.Color.csproj", "{D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}"
+EndProject
+```
+
+And add these rows to the `GlobalSection(ProjectConfigurationPlatforms)` block (after the `Semantics.Music` rows, before `EndGlobalSection`):
+
+```
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x64.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Debug|x86.Build.0 = Debug|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x64.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x64.Build.0 = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
+ {D4E5F6A7-B8C9-0123-DEF0-456789ABCDEF}.Release|x86.Build.0 = Release|Any CPU
+```
+
+- [ ] **Step 3: Reference the project from the test project**
+
+In `Semantics.Test/Semantics.Test.csproj`, add inside the existing `` of project references (after the `Semantics.Music` line):
+
+```xml
+
+```
+
+- [ ] **Step 4: Write the failing test**
+
+Create `Semantics.Test/Colors/ColorCoreTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System.Numerics;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class ColorCoreTests
+{
+ [TestMethod]
+ public void FromLinear_StoresChannelsAndDefaultsAlphaToOne()
+ {
+ Color c = Color.FromLinear(0.1, 0.2, 0.3);
+ Assert.AreEqual(0.1, c.R, 1e-12);
+ Assert.AreEqual(0.2, c.G, 1e-12);
+ Assert.AreEqual(0.3, c.B, 1e-12);
+ Assert.AreEqual(1.0, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void WithAlpha_ReplacesAlphaOnly()
+ {
+ Color c = Color.FromLinear(0.1, 0.2, 0.3, 1.0).WithAlpha(0.5);
+ Assert.AreEqual(0.1, c.R, 1e-12);
+ Assert.AreEqual(0.5, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void Clamp_BringsChannelsIntoUnitRange()
+ {
+ Color c = Color.FromLinear(-0.5, 0.5, 1.5, 2.0).Clamp();
+ Assert.AreEqual(0.0, c.R, 1e-12);
+ Assert.AreEqual(0.5, c.G, 1e-12);
+ Assert.AreEqual(1.0, c.B, 1e-12);
+ Assert.AreEqual(1.0, c.A, 1e-12);
+ }
+
+ [TestMethod]
+ public void ToLinearVector4_ReturnsFloatChannels()
+ {
+ Vector4 v = Color.FromLinear(0.1, 0.2, 0.3, 0.4).ToLinearVector4();
+ Assert.AreEqual(0.1f, v.X, 1e-6f);
+ Assert.AreEqual(0.4f, v.W, 1e-6f);
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~ColorCoreTests"`
+Expected: FAIL — `Color` does not exist / does not compile.
+
+- [ ] **Step 6: Implement `Color.cs`**
+
+Create `Semantics.Color/Color.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+using System.Numerics;
+
+///
+/// A color stored as linear (not gamma-encoded) RGB plus straight alpha, each in the range 0..1.
+/// This is the canonical color type; conversions to and from other color spaces live in the
+/// Color.Conversions partial, and color-science operations in Color.Operations.
+///
+/// Linear red channel.
+/// Linear green channel.
+/// Linear blue channel.
+/// Straight (non-premultiplied) alpha.
+public readonly partial record struct Color(double R, double G, double B, double A)
+{
+ /// Creates a color from linear RGB channels, defaulting alpha to fully opaque.
+ /// Linear red channel.
+ /// Linear green channel.
+ /// Linear blue channel.
+ /// Straight alpha (default 1.0).
+ /// A linear-RGB color.
+ public static Color FromLinear(double r, double g, double b, double a = 1.0) => new(r, g, b, a);
+
+ /// Returns a copy of this color with a replaced alpha.
+ /// The new straight alpha.
+ /// A color with the same RGB and the given alpha.
+ public Color WithAlpha(double a) => new(R, G, B, a);
+
+ /// Returns a copy with every channel clamped to the 0..1 range.
+ /// A gamut- and alpha-clamped color.
+ public Color Clamp() => new(Clamp01(R), Clamp01(G), Clamp01(B), Clamp01(A));
+
+ /// Converts to a linear-RGBA (float).
+ /// A float vector of the linear channels.
+ public Vector4 ToLinearVector4() => new((float)R, (float)G, (float)B, (float)A);
+
+ /// Converts to a linear-RGB (float), dropping alpha.
+ /// A float vector of the linear RGB channels.
+ public Vector3 ToLinearVector3() => new((float)R, (float)G, (float)B);
+
+ internal static double Clamp01(double value) => value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
+}
+```
+
+- [ ] **Step 7: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~ColorCoreTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add Semantics.Color/ Semantics.sln Semantics.Test/Semantics.Test.csproj Semantics.Test/Colors/ColorCoreTests.cs
+git commit -m "feat(color): scaffold Semantics.Color with canonical linear Color type"
+```
+
+---
+
+### Task 2: `Srgb` struct + gamma boundary
+
+**Files:**
+- Create: `Semantics.Color/Srgb.cs`
+- Create: `Semantics.Color/Color.Conversions.cs`
+- Test: `Semantics.Test/Colors/SrgbConversionTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.FromLinear`, `Color` ctor.
+- Produces: `readonly record struct Srgb(double R, double G, double B)` with `Color ToLinear(double a = 1.0)` and `static Srgb FromLinear(Color color)`; on `Color` (partial): `static Color FromSrgb(Srgb srgb, double a = 1.0)`, `static Color FromSrgb(double r, double g, double b, double a = 1.0)`, `Srgb ToSrgb()`, `Vector4 ToSrgbVector4()`, `Vector3 ToSrgbVector3()`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/SrgbConversionTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System.Numerics;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class SrgbConversionTests
+{
+ [TestMethod]
+ public void SrgbToLinearToSrgb_RoundTripsToIdentity()
+ {
+ for (int i = 0; i <= 100; i++)
+ {
+ double channel = i / 100.0;
+ Srgb original = new(channel, channel, channel);
+ Srgb roundTripped = original.ToLinear().ToSrgb();
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ }
+ }
+
+ [TestMethod]
+ public void Srgb_MidGray_DecodesToSmallerLinearValue()
+ {
+ // sRGB 0.5 is perceptual mid-gray; its linear value is ~0.214 (proves gamma decode happens).
+ Color c = Color.FromSrgb(0.5, 0.5, 0.5);
+ Assert.AreEqual(0.21404, c.R, 1e-4);
+ }
+
+ [TestMethod]
+ public void Srgb_EndpointsMapToLinearEndpoints()
+ {
+ Assert.AreEqual(0.0, Color.FromSrgb(0.0, 0.0, 0.0).R, 1e-12);
+ Assert.AreEqual(1.0, Color.FromSrgb(1.0, 1.0, 1.0).R, 1e-12);
+ }
+
+ [TestMethod]
+ public void ToSrgbVector4_ReturnsGammaEncodedChannels()
+ {
+ Color c = Color.FromSrgb(0.5, 0.5, 0.5, 1.0);
+ Vector4 v = c.ToSrgbVector4();
+ Assert.AreEqual(0.5f, v.X, 1e-4f);
+ Assert.AreEqual(1.0f, v.W, 1e-6f);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~SrgbConversionTests"`
+Expected: FAIL — `Srgb` / `FromSrgb` / `ToSrgbVector4` not defined.
+
+- [ ] **Step 3: Implement `Srgb.cs`**
+
+Create `Semantics.Color/Srgb.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the gamma-encoded sRGB space, each channel 0..1. This is the only color space that
+/// crosses the gamma boundary to and from the linear .
+///
+/// Gamma-encoded red channel.
+/// Gamma-encoded green channel.
+/// Gamma-encoded blue channel.
+public readonly record struct Srgb(double R, double G, double B)
+{
+ /// Converts this sRGB color to a linear .
+ /// Straight alpha for the resulting color (default 1.0).
+ /// The linear-RGB equivalent.
+ public Color ToLinear(double a = 1.0) => new(DecodeChannel(R), DecodeChannel(G), DecodeChannel(B), a);
+
+ /// Converts a linear to gamma-encoded sRGB (alpha dropped).
+ /// The linear color.
+ /// The sRGB equivalent.
+ public static Srgb FromLinear(Color color) =>
+ new(EncodeChannel(color.R), EncodeChannel(color.G), EncodeChannel(color.B));
+
+ private static double DecodeChannel(double s) =>
+ s <= 0.04045 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4);
+
+ private static double EncodeChannel(double linear) =>
+ linear <= 0.0031308 ? 12.92 * linear : (1.055 * Math.Pow(linear, 1.0 / 2.4)) - 0.055;
+}
+```
+
+- [ ] **Step 4: Implement the sRGB members of `Color.Conversions.cs`**
+
+Create `Semantics.Color/Color.Conversions.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System.Numerics;
+
+public readonly partial record struct Color
+{
+ /// Creates a linear color from a gamma-encoded .
+ /// The sRGB color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromSrgb(Srgb srgb, double a = 1.0) => srgb.ToLinear(a);
+
+ /// Creates a linear color from gamma-encoded sRGB channels.
+ /// sRGB red channel (0..1).
+ /// sRGB green channel (0..1).
+ /// sRGB blue channel (0..1).
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromSrgb(double r, double g, double b, double a = 1.0) => new Srgb(r, g, b).ToLinear(a);
+
+ /// Converts this linear color to gamma-encoded .
+ /// The sRGB equivalent (alpha dropped).
+ public Srgb ToSrgb() => Srgb.FromLinear(this);
+
+ /// Converts to a gamma-encoded sRGB (float) — the value ImGui expects.
+ /// A float vector of sRGB RGB plus alpha.
+ public Vector4 ToSrgbVector4()
+ {
+ Srgb s = ToSrgb();
+ return new Vector4((float)s.R, (float)s.G, (float)s.B, (float)A);
+ }
+
+ /// Converts to a gamma-encoded sRGB (float), dropping alpha.
+ /// A float vector of sRGB RGB.
+ public Vector3 ToSrgbVector3()
+ {
+ Srgb s = ToSrgb();
+ return new Vector3((float)s.R, (float)s.G, (float)s.B);
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~SrgbConversionTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Semantics.Color/Srgb.cs Semantics.Color/Color.Conversions.cs Semantics.Test/Colors/SrgbConversionTests.cs
+git commit -m "feat(color): add Srgb space and gamma-correct sRGB<->linear boundary"
+```
+
+---
+
+### Task 3: Hex and byte conversions
+
+**Files:**
+- Modify: `Semantics.Color/Color.Conversions.cs` (add members)
+- Test: `Semantics.Test/Colors/HexConversionTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.FromSrgb`, `Color.ToSrgb`.
+- Produces: on `Color` (partial): `static Color FromHex(string hex)`, `string ToHex()` (uppercase `#RRGGBB`, or `#RRGGBBAA` when alpha < 1), `static Color FromBytes(byte r, byte g, byte b, byte a = 255)`, `(byte R, byte G, byte B, byte A) ToBytes()`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/HexConversionTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class HexConversionTests
+{
+ [TestMethod]
+ public void FromHex_ParsesSixDigitAsOpaqueSrgb()
+ {
+ Color red = Color.FromHex("#FF0000");
+ Assert.AreEqual(1.0, red.A, 1e-12);
+ (byte r, byte g, byte b, byte a) = red.ToBytes();
+ Assert.AreEqual((byte)255, r);
+ Assert.AreEqual((byte)0, g);
+ Assert.AreEqual((byte)0, b);
+ Assert.AreEqual((byte)255, a);
+ }
+
+ [TestMethod]
+ public void FromHex_ParsesEightDigitAlpha()
+ {
+ Color half = Color.FromHex("#00000080");
+ Assert.AreEqual(128.0 / 255.0, half.A, 1e-6);
+ }
+
+ [TestMethod]
+ public void FromHex_ParsesThreeDigitShorthand()
+ {
+ Color a = Color.FromHex("#F00");
+ Color b = Color.FromHex("#FF0000");
+ Assert.AreEqual(b.R, a.R, 1e-12);
+ }
+
+ [TestMethod]
+ public void FromHex_AcceptsNoLeadingHash()
+ {
+ (byte r, _, _, _) = Color.FromHex("00FF00").ToBytes();
+ Assert.AreEqual((byte)0, r);
+ }
+
+ [TestMethod]
+ public void HexRoundTrip_IsStable()
+ {
+ Assert.AreEqual("#3A7BD5", Color.FromHex("#3A7BD5").ToHex());
+ }
+
+ [TestMethod]
+ public void ToHex_EmitsAlphaWhenNotOpaque()
+ {
+ Assert.AreEqual("#3A7BD580", Color.FromHex("#3A7BD580").ToHex());
+ }
+
+ [TestMethod]
+ public void FromHex_InvalidLength_Throws() =>
+ Assert.ThrowsException(() => Color.FromHex("#12345"));
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~HexConversionTests"`
+Expected: FAIL — `FromHex`/`ToHex`/`FromBytes`/`ToBytes` not defined.
+
+- [ ] **Step 3: Add hex/byte members to `Color.Conversions.cs`**
+
+Add inside the `public readonly partial record struct Color` body in `Semantics.Color/Color.Conversions.cs`, and add `using System;`, `using System.Globalization;` to that file's `using` block:
+
+```csharp
+ /// Creates a linear color from a hex string: #RGB, #RRGGBB, or #RRGGBBAA (leading '#' optional). Channels are interpreted as sRGB.
+ /// The hex color string.
+ /// The linear-RGB color.
+ /// Thrown when is null.
+ /// Thrown when is not a recognised hex length.
+ public static Color FromHex(string hex)
+ {
+ Ensure.NotNull(hex);
+
+ string h = hex.StartsWith("#", StringComparison.Ordinal) ? hex.Substring(1) : hex;
+
+ if (h.Length == 3)
+ {
+ h = new string([h[0], h[0], h[1], h[1], h[2], h[2]]);
+ }
+
+ if (h.Length is not (6 or 8))
+ {
+ throw new ArgumentException("Hex color must be #RGB, #RRGGBB, or #RRGGBBAA.", nameof(hex));
+ }
+
+ byte r = ParseByte(h, 0);
+ byte g = ParseByte(h, 2);
+ byte b = ParseByte(h, 4);
+ byte a = h.Length == 8 ? ParseByte(h, 6) : (byte)255;
+ return FromBytes(r, g, b, a);
+ }
+
+ /// Converts to an uppercase hex string: #RRGGBB, or #RRGGBBAA when alpha is not fully opaque.
+ /// The hex string.
+ public string ToHex()
+ {
+ (byte r, byte g, byte b, byte a) = ToBytes();
+ return a == 255
+ ? $"#{r:X2}{g:X2}{b:X2}"
+ : $"#{r:X2}{g:X2}{b:X2}{a:X2}";
+ }
+
+ /// Creates a linear color from 8-bit sRGB channels.
+ /// sRGB red byte.
+ /// sRGB green byte.
+ /// sRGB blue byte.
+ /// Alpha byte (default 255).
+ /// The linear-RGB color.
+ public static Color FromBytes(byte r, byte g, byte b, byte a = 255) =>
+ FromSrgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0);
+
+ /// Converts to 8-bit sRGB channels plus an alpha byte.
+ /// The rounded sRGB byte tuple.
+ public (byte R, byte G, byte B, byte A) ToBytes()
+ {
+ Srgb s = ToSrgb();
+ return (ToByte(s.R), ToByte(s.G), ToByte(s.B), ToByte(A));
+ }
+
+ private static byte ParseByte(string hex, int index) =>
+ byte.Parse(hex.Substring(index, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+
+ private static byte ToByte(double channel)
+ {
+ double scaled = Math.Round(Clamp01(channel) * 255.0);
+ return (byte)scaled;
+ }
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~HexConversionTests"`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Semantics.Color/Color.Conversions.cs Semantics.Test/Colors/HexConversionTests.cs
+git commit -m "feat(color): add hex and byte conversions (sRGB-interpreted)"
+```
+
+---
+
+### Task 4: `Oklab` and `Oklch`
+
+**Files:**
+- Create: `Semantics.Color/Oklab.cs`
+- Create: `Semantics.Color/Oklch.cs`
+- Modify: `Semantics.Color/Color.Conversions.cs`
+- Test: `Semantics.Test/Colors/OklabConversionTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.FromLinear`, `Color` ctor.
+- Produces: `readonly record struct Oklab(double L, double A, double B)` with `Color ToColor(double a = 1.0)`, `static Oklab FromColor(Color color)`, `Oklch ToOklch()`; `readonly record struct Oklch(double L, double C, double H)` with `Oklab ToOklab()`. On `Color` (partial): `Oklab ToOklab()`, `static Color FromOklab(Oklab oklab, double a = 1.0)`, `Oklch ToOklch()`, `static Color FromOklch(Oklch oklch, double a = 1.0)`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/OklabConversionTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class OklabConversionTests
+{
+ [TestMethod]
+ public void White_HasLightnessOneAndNoChroma()
+ {
+ // Reference (Ottosson): linear white -> Oklab L=1, a=0, b=0.
+ Oklab lab = Color.FromLinear(1.0, 1.0, 1.0).ToOklab();
+ Assert.AreEqual(1.0, lab.L, 1e-4);
+ Assert.AreEqual(0.0, lab.A, 1e-4);
+ Assert.AreEqual(0.0, lab.B, 1e-4);
+ }
+
+ [TestMethod]
+ public void OklabRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromOklab(original.ToOklab());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+
+ [TestMethod]
+ public void Oklch_RedHasPositiveChroma()
+ {
+ Oklch lch = Color.FromSrgb(1.0, 0.0, 0.0).ToOklch();
+ Assert.IsTrue(lch.C > 0.1, $"expected chroma > 0.1 but was {lch.C}");
+ }
+
+ [TestMethod]
+ public void OklchRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromOklch(original.ToOklch());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~OklabConversionTests"`
+Expected: FAIL — `Oklab`/`Oklch`/`ToOklab` not defined.
+
+- [ ] **Step 3: Implement `Oklab.cs`**
+
+Create `Semantics.Color/Oklab.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the Oklab perceptual color space (Björn Ottosson, 2020), derived from linear RGB.
+///
+/// Perceived lightness.
+/// Green–red axis (negative green, positive red/magenta).
+/// Blue–yellow axis (negative blue, positive yellow).
+public readonly record struct Oklab(double L, double A, double B)
+{
+ /// Converts a linear to Oklab.
+ /// The linear color.
+ /// The Oklab equivalent.
+ public static Oklab FromColor(Color color)
+ {
+ double l = (0.4122214708 * color.R) + (0.5363325363 * color.G) + (0.0514459929 * color.B);
+ double m = (0.2119034982 * color.R) + (0.6806995451 * color.G) + (0.1073969566 * color.B);
+ double s = (0.0883024619 * color.R) + (0.2817188376 * color.G) + (0.6299787005 * color.B);
+
+ double l_ = Cbrt(l);
+ double m_ = Cbrt(m);
+ double s_ = Cbrt(s);
+
+ return new Oklab(
+ (0.2104542553 * l_) + (0.7936177850 * m_) - (0.0040720468 * s_),
+ (1.9779984951 * l_) - (2.4285922050 * m_) + (0.4505937099 * s_),
+ (0.0259040371 * l_) + (0.7827717662 * m_) - (0.8086757660 * s_));
+ }
+
+ /// Converts this Oklab color to a linear .
+ /// Straight alpha for the result (default 1.0).
+ /// The linear-RGB equivalent.
+ public Color ToColor(double a = 1.0)
+ {
+ double l_ = L + (0.3963377774 * A) + (0.2158037573 * B);
+ double m_ = L - (0.1055613458 * A) - (0.0638541728 * B);
+ double s_ = L - (0.0894841775 * A) - (1.2914855480 * B);
+
+ double l = l_ * l_ * l_;
+ double m = m_ * m_ * m_;
+ double s = s_ * s_ * s_;
+
+ return new Color(
+ (+4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s),
+ (-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s),
+ (-0.0041960863 * l) - (0.7034186147 * m) + (1.7076147010 * s),
+ a);
+ }
+
+ /// Converts this Oklab color to its polar form.
+ /// The Oklch equivalent.
+ public Oklch ToOklch()
+ {
+ double c = Math.Sqrt((A * A) + (B * B));
+ double h = Math.Atan2(B, A) * (180.0 / Math.PI);
+ if (h < 0.0)
+ {
+ h += 360.0;
+ }
+
+ return new Oklch(L, c, h);
+ }
+
+ // netstandard2.0 lacks Math.Cbrt; a sign-aware Pow is correct for all inputs.
+ private static double Cbrt(double value) => Math.Sign(value) * Math.Pow(Math.Abs(value), 1.0 / 3.0);
+}
+```
+
+- [ ] **Step 4: Implement `Oklch.cs`**
+
+Create `Semantics.Color/Oklch.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in the polar (lightness, chroma, hue) form of Oklab. Hue is in degrees, 0..360.
+///
+/// Perceived lightness.
+/// Chroma (colourfulness).
+/// Hue angle in degrees, 0..360.
+public readonly record struct Oklch(double L, double C, double H)
+{
+ /// Converts this polar color back to Cartesian .
+ /// The Oklab equivalent.
+ public Oklab ToOklab()
+ {
+ double hRad = H * (Math.PI / 180.0);
+ return new Oklab(L, C * Math.Cos(hRad), C * Math.Sin(hRad));
+ }
+}
+```
+
+- [ ] **Step 5: Add Oklab/Oklch members to `Color.Conversions.cs`**
+
+Add inside the `Color` partial in `Semantics.Color/Color.Conversions.cs`:
+
+```csharp
+ /// Converts this linear color to .
+ /// The Oklab equivalent.
+ public Oklab ToOklab() => Oklab.FromColor(this);
+
+ /// Creates a linear color from an value.
+ /// The Oklab color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromOklab(Oklab oklab, double a = 1.0) => oklab.ToColor(a);
+
+ /// Converts this linear color to .
+ /// The Oklch equivalent.
+ public Oklch ToOklch() => Oklab.FromColor(this).ToOklch();
+
+ /// Creates a linear color from an value.
+ /// The Oklch color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromOklch(Oklch oklch, double a = 1.0) => oklch.ToOklab().ToColor(a);
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~OklabConversionTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add Semantics.Color/Oklab.cs Semantics.Color/Oklch.cs Semantics.Color/Color.Conversions.cs Semantics.Test/Colors/OklabConversionTests.cs
+git commit -m "feat(color): add Oklab and Oklch perceptual spaces"
+```
+
+---
+
+### Task 5: `Hsl` and `Hsv`
+
+**Files:**
+- Create: `Semantics.Color/Hsl.cs`
+- Create: `Semantics.Color/Hsv.cs`
+- Modify: `Semantics.Color/Color.Conversions.cs`
+- Test: `Semantics.Test/Colors/HslHsvConversionTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.FromSrgb`, `Color.ToSrgb`, `Srgb`.
+- Produces: `readonly record struct Hsl(double H, double S, double L)` with `static Hsl FromSrgb(Srgb srgb)`, `Srgb ToSrgb()`; `readonly record struct Hsv(double H, double S, double V)` with `static Hsv FromSrgb(Srgb srgb)`, `Srgb ToSrgb()`. On `Color` (partial): `Hsl ToHsl()`, `static Color FromHsl(Hsl hsl, double a = 1.0)`, `Hsv ToHsv()`, `static Color FromHsv(Hsv hsv, double a = 1.0)`. Hue is degrees 0..360.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/HslHsvConversionTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class HslHsvConversionTests
+{
+ [TestMethod]
+ public void Red_HasZeroHueFullSaturation()
+ {
+ Hsl hsl = Color.FromSrgb(1.0, 0.0, 0.0).ToHsl();
+ Assert.AreEqual(0.0, hsl.H, 1e-6);
+ Assert.AreEqual(1.0, hsl.S, 1e-6);
+ Assert.AreEqual(0.5, hsl.L, 1e-6);
+ }
+
+ [TestMethod]
+ public void Green_HasHue120()
+ {
+ Hsv hsv = Color.FromSrgb(0.0, 1.0, 0.0).ToHsv();
+ Assert.AreEqual(120.0, hsv.H, 1e-4);
+ Assert.AreEqual(1.0, hsv.S, 1e-6);
+ Assert.AreEqual(1.0, hsv.V, 1e-6);
+ }
+
+ [TestMethod]
+ public void HslRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.2, 0.6, 0.9);
+ Color roundTripped = Color.FromHsl(original.ToHsl());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+
+ [TestMethod]
+ public void HsvRoundTrip_IsIdentity()
+ {
+ Color original = Color.FromSrgb(0.7, 0.3, 0.55);
+ Color roundTripped = Color.FromHsv(original.ToHsv());
+ Assert.AreEqual(original.R, roundTripped.R, 1e-9);
+ Assert.AreEqual(original.G, roundTripped.G, 1e-9);
+ Assert.AreEqual(original.B, roundTripped.B, 1e-9);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~HslHsvConversionTests"`
+Expected: FAIL — `Hsl`/`Hsv`/`ToHsl`/`ToHsv` not defined.
+
+- [ ] **Step 3: Implement `Hsl.cs`**
+
+Create `Semantics.Color/Hsl.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in HSL (hue, saturation, lightness), defined over the gamma-encoded sRGB channels.
+/// Hue is in degrees, 0..360; saturation and lightness are 0..1.
+///
+/// Hue angle in degrees, 0..360.
+/// Saturation, 0..1.
+/// Lightness, 0..1.
+public readonly record struct Hsl(double H, double S, double L)
+{
+ /// Converts a gamma-encoded color to HSL.
+ /// The sRGB color.
+ /// The HSL equivalent.
+ public static Hsl FromSrgb(Srgb srgb)
+ {
+ double max = Math.Max(srgb.R, Math.Max(srgb.G, srgb.B));
+ double min = Math.Min(srgb.R, Math.Min(srgb.G, srgb.B));
+ double l = (max + min) / 2.0;
+ double h = 0.0;
+ double s = 0.0;
+
+ if (max > min)
+ {
+ double d = max - min;
+ s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min);
+ h = HueDegrees(srgb, max, d);
+ }
+
+ return new Hsl(h, s, l);
+ }
+
+ /// Converts this HSL color to a gamma-encoded .
+ /// The sRGB equivalent.
+ public Srgb ToSrgb()
+ {
+ double h = NormalizeHue(H) / 360.0;
+ if (S <= 0.0)
+ {
+ return new Srgb(L, L, L);
+ }
+
+ double q = L < 0.5 ? L * (1.0 + S) : L + S - (L * S);
+ double p = (2.0 * L) - q;
+ return new Srgb(
+ HueToChannel(p, q, h + (1.0 / 3.0)),
+ HueToChannel(p, q, h),
+ HueToChannel(p, q, h - (1.0 / 3.0)));
+ }
+
+ internal static double NormalizeHue(double h)
+ {
+ double r = h % 360.0;
+ return r < 0.0 ? r + 360.0 : r;
+ }
+
+ internal static double HueDegrees(Srgb srgb, double max, double d)
+ {
+ double h;
+ if (max == srgb.R)
+ {
+ h = ((srgb.G - srgb.B) / d) + (srgb.G < srgb.B ? 6.0 : 0.0);
+ }
+ else if (max == srgb.G)
+ {
+ h = ((srgb.B - srgb.R) / d) + 2.0;
+ }
+ else
+ {
+ h = ((srgb.R - srgb.G) / d) + 4.0;
+ }
+
+ return h * 60.0;
+ }
+
+ private static double HueToChannel(double p, double q, double t)
+ {
+ if (t < 0.0)
+ {
+ t += 1.0;
+ }
+
+ if (t > 1.0)
+ {
+ t -= 1.0;
+ }
+
+ if (t < 1.0 / 6.0)
+ {
+ return p + ((q - p) * 6.0 * t);
+ }
+
+ if (t < 1.0 / 2.0)
+ {
+ return q;
+ }
+
+ if (t < 2.0 / 3.0)
+ {
+ return p + ((q - p) * ((2.0 / 3.0) - t) * 6.0);
+ }
+
+ return p;
+ }
+}
+```
+
+- [ ] **Step 4: Implement `Hsv.cs`**
+
+Create `Semantics.Color/Hsv.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+///
+/// A color in HSV (hue, saturation, value), defined over the gamma-encoded sRGB channels.
+/// Hue is in degrees, 0..360; saturation and value are 0..1.
+///
+/// Hue angle in degrees, 0..360.
+/// Saturation, 0..1.
+/// Value (brightness), 0..1.
+public readonly record struct Hsv(double H, double S, double V)
+{
+ /// Converts a gamma-encoded color to HSV.
+ /// The sRGB color.
+ /// The HSV equivalent.
+ public static Hsv FromSrgb(Srgb srgb)
+ {
+ double max = Math.Max(srgb.R, Math.Max(srgb.G, srgb.B));
+ double min = Math.Min(srgb.R, Math.Min(srgb.G, srgb.B));
+ double d = max - min;
+ double h = d > 0.0 ? Hsl.HueDegrees(srgb, max, d) : 0.0;
+ double s = max > 0.0 ? d / max : 0.0;
+ return new Hsv(h, s, max);
+ }
+
+ /// Converts this HSV color to a gamma-encoded .
+ /// The sRGB equivalent.
+ public Srgb ToSrgb()
+ {
+ double h = Hsl.NormalizeHue(H) / 60.0;
+ double c = V * S;
+ double x = c * (1.0 - Math.Abs((h % 2.0) - 1.0));
+ double m = V - c;
+
+ (double r, double g, double b) = h switch
+ {
+ < 1.0 => (c, x, 0.0),
+ < 2.0 => (x, c, 0.0),
+ < 3.0 => (0.0, c, x),
+ < 4.0 => (0.0, x, c),
+ < 5.0 => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+
+ return new Srgb(r + m, g + m, b + m);
+ }
+}
+```
+
+- [ ] **Step 5: Add HSL/HSV members to `Color.Conversions.cs`**
+
+Add inside the `Color` partial in `Semantics.Color/Color.Conversions.cs`:
+
+```csharp
+ /// Converts this linear color to (via sRGB).
+ /// The HSL equivalent.
+ public Hsl ToHsl() => Hsl.FromSrgb(ToSrgb());
+
+ /// Creates a linear color from an value (via sRGB).
+ /// The HSL color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromHsl(Hsl hsl, double a = 1.0) => FromSrgb(hsl.ToSrgb(), a);
+
+ /// Converts this linear color to (via sRGB).
+ /// The HSV equivalent.
+ public Hsv ToHsv() => Hsv.FromSrgb(ToSrgb());
+
+ /// Creates a linear color from an value (via sRGB).
+ /// The HSV color.
+ /// Straight alpha (default 1.0).
+ /// The linear-RGB color.
+ public static Color FromHsv(Hsv hsv, double a = 1.0) => FromSrgb(hsv.ToSrgb(), a);
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~HslHsvConversionTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add Semantics.Color/Hsl.cs Semantics.Color/Hsv.cs Semantics.Color/Color.Conversions.cs Semantics.Test/Colors/HslHsvConversionTests.cs
+git commit -m "feat(color): add HSL and HSV conversions"
+```
+
+---
+
+### Task 6: WCAG luminance, contrast, accessibility
+
+**Files:**
+- Create: `Semantics.Color/AccessibilityLevel.cs`
+- Create: `Semantics.Color/Color.Operations.cs`
+- Test: `Semantics.Test/Colors/AccessibilityTests.cs`
+
+**Interfaces:**
+- Consumes: `Color`, `Color.ToOklab`, `Color.FromOklab`, `Color.Clamp`, `Oklab`.
+- Produces: `enum AccessibilityLevel { Fail, AA, AAA }`. On `Color` (partial): `double RelativeLuminance` (property), `double ContrastRatio(Color other)`, `AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false)`, `Color AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/AccessibilityTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class AccessibilityTests
+{
+ [TestMethod]
+ public void BlackOnWhite_HasMaximumContrast()
+ {
+ Color black = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color white = Color.FromSrgb(1.0, 1.0, 1.0);
+ Assert.AreEqual(21.0, black.ContrastRatio(white), 1e-2);
+ }
+
+ [TestMethod]
+ public void SameColor_HasUnitContrast()
+ {
+ Color gray = Color.FromSrgb(0.5, 0.5, 0.5);
+ Assert.AreEqual(1.0, gray.ContrastRatio(gray), 1e-9);
+ }
+
+ [TestMethod]
+ public void BlackOnWhite_RatesAaa()
+ {
+ Color black = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color white = Color.FromSrgb(1.0, 1.0, 1.0);
+ Assert.AreEqual(AccessibilityLevel.AAA, black.AccessibilityLevelAgainst(white));
+ }
+
+ [TestMethod]
+ public void AdjustForContrast_ReachesRequestedLevel()
+ {
+ Color background = Color.FromSrgb(1.0, 1.0, 1.0);
+ Color faint = Color.FromSrgb(0.85, 0.85, 0.2);
+ Color adjusted = faint.AdjustForContrast(background, AccessibilityLevel.AA);
+ Assert.IsTrue(
+ adjusted.AccessibilityLevelAgainst(background) >= AccessibilityLevel.AA,
+ $"contrast was {adjusted.ContrastRatio(background)}");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~AccessibilityTests"`
+Expected: FAIL — `AccessibilityLevel`/`ContrastRatio` not defined.
+
+- [ ] **Step 3: Implement `AccessibilityLevel.cs`**
+
+Create `Semantics.Color/AccessibilityLevel.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+/// WCAG 2.x contrast conformance levels, ordered so that higher is stricter.
+public enum AccessibilityLevel
+{
+ /// Does not meet the AA contrast threshold.
+ Fail = 0,
+
+ /// Meets the WCAG AA contrast threshold.
+ AA = 1,
+
+ /// Meets the WCAG AAA contrast threshold.
+ AAA = 2,
+}
+```
+
+- [ ] **Step 4: Implement `Color.Operations.cs`**
+
+Create `Semantics.Color/Color.Operations.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+
+public readonly partial record struct Color
+{
+ /// Gets the WCAG relative luminance of this color (computed on the linear channels).
+ public double RelativeLuminance => (0.2126 * R) + (0.7152 * G) + (0.0722 * B);
+
+ /// Computes the WCAG contrast ratio (1..21) between this color and another.
+ /// The other color.
+ /// The contrast ratio, from 1 (identical luminance) to 21 (black vs white).
+ public double ContrastRatio(Color other)
+ {
+ double l1 = RelativeLuminance;
+ double l2 = other.RelativeLuminance;
+ double lighter = Math.Max(l1, l2);
+ double darker = Math.Min(l1, l2);
+ return (lighter + 0.05) / (darker + 0.05);
+ }
+
+ /// Rates the contrast of this color against a background per WCAG.
+ /// The background color.
+ /// True for large text (lower thresholds).
+ /// The highest the pair satisfies.
+ public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false)
+ {
+ double contrast = ContrastRatio(background);
+ if (contrast >= (largeText ? 4.5 : 7.0))
+ {
+ return AccessibilityLevel.AAA;
+ }
+
+ return contrast >= (largeText ? 3.0 : 4.5) ? AccessibilityLevel.AA : AccessibilityLevel.Fail;
+ }
+
+ ///
+ /// Adjusts this color's Oklab lightness (preserving hue and chroma) until it meets the requested
+ /// contrast level against a background. Returns this color unchanged if already sufficient or if
+ /// no adjustment can reach the target.
+ ///
+ /// The background color.
+ /// The desired conformance level.
+ /// True for large text (lower thresholds).
+ /// An adjusted color, clamped to gamut.
+ public Color AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)
+ {
+ double required = target switch
+ {
+ AccessibilityLevel.AAA => largeText ? 4.5 : 7.0,
+ AccessibilityLevel.AA => largeText ? 3.0 : 4.5,
+ _ => 1.0,
+ };
+
+ if (ContrastRatio(background) >= required)
+ {
+ return this;
+ }
+
+ Oklab lab = ToOklab();
+ bool goLighter = background.RelativeLuminance < 0.5;
+ double lo = goLighter ? lab.L : 0.0;
+ double hi = goLighter ? 1.0 : lab.L;
+
+ // Contrast increases monotonically as L moves toward the chosen extreme; binary-search the
+ // smallest movement that meets the requirement.
+ for (int i = 0; i < 30; i++)
+ {
+ double mid = (lo + hi) / 2.0;
+ Color candidate = Candidate(lab, mid);
+ bool meets = candidate.ContrastRatio(background) >= required;
+ if (goLighter)
+ {
+ if (meets)
+ {
+ hi = mid;
+ }
+ else
+ {
+ lo = mid;
+ }
+ }
+ else if (meets)
+ {
+ lo = mid;
+ }
+ else
+ {
+ hi = mid;
+ }
+ }
+
+ Color result = Candidate(lab, goLighter ? hi : lo);
+ return result.ContrastRatio(background) >= required ? result : this;
+
+ Color Candidate(Oklab source, double lightness) =>
+ FromOklab(new Oklab(lightness, source.A, source.B), A).Clamp();
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~AccessibilityTests"`
+Expected: PASS (4 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Semantics.Color/AccessibilityLevel.cs Semantics.Color/Color.Operations.cs Semantics.Test/Colors/AccessibilityTests.cs
+git commit -m "feat(color): add WCAG luminance, contrast, and accessibility adjustment"
+```
+
+---
+
+### Task 7: Mixing, interpolation, distance, gradients
+
+**Files:**
+- Modify: `Semantics.Color/Color.Operations.cs`
+- Test: `Semantics.Test/Colors/ColorOperationTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.ToOklab`, `Color.FromOklab`, `Oklab`.
+- Produces: on `Color` (partial): `double DistanceTo(Color other)` (Oklab Euclidean), `Color MixOklab(Color other, double t)`, `Color Lerp(Color other, double t)` (linear), `IReadOnlyList Gradient(Color to, int steps)` (perceptual; throws `ArgumentException` when `steps < 2`).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Semantics.Test/Colors/ColorOperationTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using System;
+using System.Collections.Generic;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class ColorOperationTests
+{
+ [TestMethod]
+ public void DistanceTo_Self_IsZero()
+ {
+ Color c = Color.FromSrgb(0.3, 0.6, 0.9);
+ Assert.AreEqual(0.0, c.DistanceTo(c), 1e-12);
+ }
+
+ [TestMethod]
+ public void DistanceTo_IsSymmetric()
+ {
+ Color a = Color.FromSrgb(0.1, 0.2, 0.3);
+ Color b = Color.FromSrgb(0.9, 0.8, 0.7);
+ Assert.AreEqual(a.DistanceTo(b), b.DistanceTo(a), 1e-12);
+ }
+
+ [TestMethod]
+ public void Lerp_AtEndpoints_ReturnsEndpoints()
+ {
+ Color a = Color.FromLinear(0.0, 0.0, 0.0);
+ Color b = Color.FromLinear(1.0, 1.0, 1.0);
+ Assert.AreEqual(0.0, a.Lerp(b, 0.0).R, 1e-12);
+ Assert.AreEqual(1.0, a.Lerp(b, 1.0).R, 1e-12);
+ Assert.AreEqual(0.5, a.Lerp(b, 0.5).R, 1e-12);
+ }
+
+ [TestMethod]
+ public void MixOklab_Halfway_IsBetweenEndpoints()
+ {
+ Color a = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color b = Color.FromSrgb(1.0, 1.0, 1.0);
+ Color mid = a.MixOklab(b, 0.5);
+ Assert.IsTrue(mid.R > a.R && mid.R < b.R, $"mid.R was {mid.R}");
+ }
+
+ [TestMethod]
+ public void Gradient_ReturnsRequestedStepsWithMatchingEndpoints()
+ {
+ Color a = Color.FromSrgb(0.0, 0.0, 0.0);
+ Color b = Color.FromSrgb(1.0, 1.0, 1.0);
+ IReadOnlyList gradient = a.Gradient(b, 5);
+ Assert.AreEqual(5, gradient.Count);
+ Assert.AreEqual(a.R, gradient[0].R, 1e-9);
+ Assert.AreEqual(b.R, gradient[4].R, 1e-9);
+ }
+
+ [TestMethod]
+ public void Gradient_TooFewSteps_Throws() =>
+ Assert.ThrowsException(() => Color.FromSrgb(0, 0, 0).Gradient(Color.FromSrgb(1, 1, 1), 1));
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~ColorOperationTests"`
+Expected: FAIL — `DistanceTo`/`MixOklab`/`Lerp`/`Gradient` not defined.
+
+- [ ] **Step 3: Add the operations to `Color.Operations.cs`**
+
+Add `using System.Collections.Generic;` to the file's `using` block, then add inside the `Color` partial in `Semantics.Color/Color.Operations.cs`:
+
+```csharp
+ /// Computes the perceptual (Oklab Euclidean) distance to another color.
+ /// The other color.
+ /// The Oklab distance.
+ public double DistanceTo(Color other)
+ {
+ Oklab a = ToOklab();
+ Oklab b = other.ToOklab();
+ double dl = a.L - b.L;
+ double da = a.A - b.A;
+ double db = a.B - b.B;
+ return Math.Sqrt((dl * dl) + (da * da) + (db * db));
+ }
+
+ /// Mixes this color with another in Oklab space (perceptually uniform).
+ /// The other color.
+ /// The interpolation factor, 0 = this, 1 = other.
+ /// The mixed color.
+ public Color MixOklab(Color other, double t)
+ {
+ Oklab a = ToOklab();
+ Oklab b = other.ToOklab();
+ double inv = 1.0 - t;
+ Oklab mixed = new(
+ (a.L * inv) + (b.L * t),
+ (a.A * inv) + (b.A * t),
+ (a.B * inv) + (b.B * t));
+ return FromOklab(mixed, (A * inv) + (other.A * t));
+ }
+
+ /// Linearly interpolates this color with another in linear-RGB space.
+ /// The other color.
+ /// The interpolation factor, 0 = this, 1 = other.
+ /// The interpolated color.
+ public Color Lerp(Color other, double t)
+ {
+ double inv = 1.0 - t;
+ return new Color(
+ (R * inv) + (other.R * t),
+ (G * inv) + (other.G * t),
+ (B * inv) + (other.B * t),
+ (A * inv) + (other.A * t));
+ }
+
+ /// Builds a perceptually-uniform (Oklab) gradient from this color to another.
+ /// The end color.
+ /// The number of colors to produce (at least 2).
+ /// The gradient, inclusive of both endpoints.
+ /// Thrown when is less than 2.
+ public IReadOnlyList Gradient(Color to, int steps)
+ {
+ if (steps < 2)
+ {
+ throw new ArgumentException("A gradient needs at least 2 steps.", nameof(steps));
+ }
+
+ Color[] result = new Color[steps];
+ for (int i = 0; i < steps; i++)
+ {
+ result[i] = MixOklab(to, i / (double)(steps - 1));
+ }
+
+ return result;
+ }
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~ColorOperationTests"`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Semantics.Color/Color.Operations.cs Semantics.Test/Colors/ColorOperationTests.cs
+git commit -m "feat(color): add Oklab mix, lerp, distance, and gradient"
+```
+
+---
+
+### Task 8: `NamedColors` + gamma-regression sweep + full build
+
+**Files:**
+- Create: `Semantics.Color/NamedColors.cs`
+- Test: `Semantics.Test/Colors/NamedColorsTests.cs`
+- Test: `Semantics.Test/Colors/GammaRegressionTests.cs`
+
+**Interfaces:**
+- Consumes: `Color.FromHex`, `Color.ToHex`, `Color.ToOklab`, `Color.FromLinear`.
+- Produces: `static class NamedColors` with `Color` properties (`Black`, `White`, `Red`, `Green`, `Blue`, `Yellow`, `Cyan`, `Magenta`, `Gray`, `Orange`, `Purple`, `Transparent`) and `IReadOnlyDictionary All` (case-insensitive) + `bool TryGet(string name, out Color color)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Semantics.Test/Colors/NamedColorsTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class NamedColorsTests
+{
+ [TestMethod]
+ public void Red_MatchesPureRedHex() =>
+ Assert.AreEqual("#FF0000", NamedColors.Red.ToHex());
+
+ [TestMethod]
+ public void Transparent_HasZeroAlpha() =>
+ Assert.AreEqual(0.0, NamedColors.Transparent.A, 1e-12);
+
+ [TestMethod]
+ public void TryGet_IsCaseInsensitive()
+ {
+ bool found = NamedColors.TryGet("ReD", out Color color);
+ Assert.IsTrue(found);
+ Assert.AreEqual("#FF0000", color.ToHex());
+ }
+
+ [TestMethod]
+ public void TryGet_UnknownName_ReturnsFalse() =>
+ Assert.IsFalse(NamedColors.TryGet("notacolor", out _));
+}
+```
+
+Create `Semantics.Test/Colors/GammaRegressionTests.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Colors;
+
+using ktsu.Semantics.Color;
+
+[TestClass]
+public class GammaRegressionTests
+{
+ [TestMethod]
+ public void HexDisplayIsStable_AcrossLinearRoundTrip()
+ {
+ // Proves the fix is display-safe: hex -> linear Color -> hex returns the same hex.
+ string[] samples = ["#000000", "#FFFFFF", "#3A7BD5", "#7F7F7F", "#FFA500"];
+ foreach (string hex in samples)
+ {
+ Assert.AreEqual(hex, Color.FromHex(hex).ToHex());
+ }
+ }
+
+ [TestMethod]
+ public void OklabFromLinear_DiffersFromNaiveSrgbAsLinear()
+ {
+ // The old bug fed sRGB values straight into the Oklab matrix. Confirm the correct (linear)
+ // computation produces a different lightness for mid-gray.
+ Color correct = Color.FromHex("#7F7F7F");
+ double correctL = correct.ToOklab().L;
+ double naiveL = Color.FromLinear(127.0 / 255.0, 127.0 / 255.0, 127.0 / 255.0).ToOklab().L;
+ Assert.AreNotEqual(naiveL, correctL, 1e-3);
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~NamedColorsTests|FullyQualifiedName~GammaRegressionTests"`
+Expected: FAIL — `NamedColors` not defined (GammaRegression compiles but cannot run until the build succeeds).
+
+- [ ] **Step 3: Implement `NamedColors.cs`**
+
+Create `Semantics.Color/NamedColors.cs`:
+
+```csharp
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Color;
+
+using System;
+using System.Collections.Generic;
+
+/// A small table of common named colors (CSS/X11 subset), as linear .
+public static class NamedColors
+{
+ /// Opaque black (#000000).
+ public static Color Black => Color.FromHex("#000000");
+
+ /// Opaque white (#FFFFFF).
+ public static Color White => Color.FromHex("#FFFFFF");
+
+ /// Opaque red (#FF0000).
+ public static Color Red => Color.FromHex("#FF0000");
+
+ /// Opaque green (#00FF00).
+ public static Color Green => Color.FromHex("#00FF00");
+
+ /// Opaque blue (#0000FF).
+ public static Color Blue => Color.FromHex("#0000FF");
+
+ /// Opaque yellow (#FFFF00).
+ public static Color Yellow => Color.FromHex("#FFFF00");
+
+ /// Opaque cyan (#00FFFF).
+ public static Color Cyan => Color.FromHex("#00FFFF");
+
+ /// Opaque magenta (#FF00FF).
+ public static Color Magenta => Color.FromHex("#FF00FF");
+
+ /// Opaque gray (#808080).
+ public static Color Gray => Color.FromHex("#808080");
+
+ /// Opaque orange (#FFA500).
+ public static Color Orange => Color.FromHex("#FFA500");
+
+ /// Opaque purple (#800080).
+ public static Color Purple => Color.FromHex("#800080");
+
+ /// Fully transparent black (#00000000).
+ public static Color Transparent => Color.FromHex("#00000000");
+
+ private static readonly Dictionary Table = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["black"] = Black,
+ ["white"] = White,
+ ["red"] = Red,
+ ["green"] = Green,
+ ["blue"] = Blue,
+ ["yellow"] = Yellow,
+ ["cyan"] = Cyan,
+ ["magenta"] = Magenta,
+ ["gray"] = Gray,
+ ["grey"] = Gray,
+ ["orange"] = Orange,
+ ["purple"] = Purple,
+ ["transparent"] = Transparent,
+ };
+
+ /// Gets all named colors keyed by name (case-insensitive lookup).
+ public static IReadOnlyDictionary All => Table;
+
+ /// Looks up a named color by name, case-insensitively.
+ /// The color name.
+ /// The resolved color, if found.
+ /// True when the name is known.
+ /// Thrown when is null.
+ public static bool TryGet(string name, out Color color)
+ {
+ Ensure.NotNull(name);
+ return Table.TryGetValue(name, out color);
+ }
+}
+```
+
+- [ ] **Step 4: Run the targeted tests to verify they pass**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~NamedColorsTests|FullyQualifiedName~GammaRegressionTests"`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Build the library across all target frameworks**
+
+Run: `dotnet build Semantics.Color/Semantics.Color.csproj`
+Expected: Build succeeded, 0 warnings, 0 errors (warnings are errors), all of `net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1`.
+
+- [ ] **Step 6: Run the full Color test suite**
+
+Run: `dotnet test Semantics.Test/Semantics.Test.csproj --filter "FullyQualifiedName~ktsu.Semantics.Test.Colors"`
+Expected: PASS (all Color tests across the 8 test classes).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add Semantics.Color/NamedColors.cs Semantics.Test/Colors/NamedColorsTests.cs Semantics.Test/Colors/GammaRegressionTests.cs
+git commit -m "feat(color): add NamedColors and gamma-regression tests"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage** (each spec item → task):
+- Package `Semantics.Color`, wide TFMs, no UI dep → Task 1.
+- Canonical `Color` (linear RGB + alpha), `double` storage, interop primitives → Tasks 1 (linear), 2 (`ToSrgbVector4`).
+- `Srgb` gamma boundary (the bug fix) → Task 2 + Task 8 regression.
+- Hex (`#RGB`/`#RRGGBB`/`#RRGGBBAA`) + bytes → Task 3.
+- `Oklab`, `Oklch` + conversions → Task 4.
+- `Hsl`, `Hsv` (v1 scope addition) + conversions → Task 5.
+- WCAG `RelativeLuminance`, `ContrastRatio`, `AccessibilityLevel`, `AccessibilityLevelAgainst`, `AdjustForContrast` → Task 6.
+- `DistanceTo`, `MixOklab`, `Lerp`, `Gradient` → Task 7.
+- `NamedColors` (v1 scope addition) → Task 8.
+- Testing: round-trips, known values, gamma regression, accessibility, NamedColors → distributed across Tasks 2–8.
+- The ImGui adapter (`ktsu.ImGui.Color`) and the ThemeProvider/ImGuiApp migrations are **out of scope for this plan** by design (separate repos / follow-on plans) — see the spec's shipping order.
+
+**Placeholder scan:** none — every code step contains complete, compilable content.
+
+**Type consistency:** `Color` is `readonly partial record struct` declared once (Task 1) and extended via `partial` in Tasks 2/4/5 (`Color.Conversions.cs`) and 6/7 (`Color.Operations.cs`). Method/property names used in tests match the produced interfaces (`FromLinear`, `FromSrgb`, `ToSrgb`, `ToSrgbVector4`, `FromHex`, `ToHex`, `FromBytes`, `ToBytes`, `ToOklab`, `FromOklab`, `ToOklch`, `FromOklch`, `ToHsl`, `FromHsl`, `ToHsv`, `FromHsv`, `RelativeLuminance`, `ContrastRatio`, `AccessibilityLevelAgainst`, `AdjustForContrast`, `DistanceTo`, `MixOklab`, `Lerp`, `Gradient`). `Hsl.HueDegrees`/`Hsl.NormalizeHue` are `internal` and reused by `Hsv` (Task 5). `AccessibilityLevel` enum ordering (`Fail=` comparison used in Task 6's test.
+
+## Notes for the implementer
+
+- `dotnet test` uses the Microsoft.Testing.Platform runner (MSTest.Sdk). The `--filter "FullyQualifiedName~..."` syntax matches by substring; a `|` separates alternatives.
+- After Task 1, every subsequent task's targeted test run also recompiles the whole library, so a break in any partial surfaces immediately.
+- Round-trip tolerances: sRGB↔linear and HSL/HSV↔sRGB conversions are exact-by-construction and round-trip to ~1e-9 in `double`. **Oklab/Oklch are NOT** — the published Ottosson forward and inverse matrices are independently-rounded 10-significant-digit constants that do not invert to exact identity, so Oklab round-trips carry ~1e-7 error; use a `1e-6` tolerance there (this was corrected during implementation; the original `1e-9` note was a spec error). If a non-Oklab round-trip drifts above ~1e-9, investigate rather than loosen — it indicates a transcription bug.
diff --git a/docs/superpowers/specs/2026-06-29-semantics-color-design.md b/docs/superpowers/specs/2026-06-29-semantics-color-design.md
new file mode 100644
index 0000000..56ab91e
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-29-semantics-color-design.md
@@ -0,0 +1,215 @@
+# Design: `Semantics.Color` + ThemeProvider / ImGuiStyler consolidation
+
+Status: approved design (2026-06-29). Implements **Phase 2** of
+`docs/roadmap-semantic-domains.md`. This spec is the input to the implementation plan.
+
+## Goal
+
+Create a single, rigorous color-science library, `Semantics.Color`, and consolidate the two existing
+color implementations (`ktsu.ThemeProvider` and `ktsu.ImGuiStyler`) onto it — removing duplication and
+fixing a latent gamma-correctness bug. Ship immediately with no compatibility shims; the consuming
+repos are updated in lockstep.
+
+## Background — what exists today
+
+- **`ktsu.ThemeProvider`** holds the real color engine: `RgbColor`, `SRgbColor` (a genuine
+ linear/sRGB split), `OklabColor` (+ LCh polar), `PerceptualColor`, and `ColorMath` (RGB↔Oklab, WCAG
+ relative luminance, contrast ratio, `AccessibilityLevel`, accessibility-driven lightness adjustment
+ via binary search, perceptually-uniform gradients). `PerceptualColor` is the **public currency type**
+ — the palette is exposed as `ImmutableDictionary`. ~40 theme
+ files construct colors via `RgbColor.FromHex` / `PerceptualColor`.
+- **`ktsu.ImGui.Styler`** (in the **ImGuiApp** monorepo, `C:\dev\ktsu-dev\ImGuiApp\ImGui.Styler\`;
+ the old standalone `ktsu.ImGuiStyler` repo is retired) has a second, weaker copy: a static `Color`
+ class with `FromHex`, `FromRGB/RGBA`, `FromHSL/HSLA` (a hand-rolled `HueToRGB`), `FromPerceptualColor`,
+ and a themed `Palette`, all producing `Hexa.NET.ImGui.ImColor`. It also depends on ThemeProvider's
+ `RgbColor`/`PerceptualColor`. The ImGuiApp repo already consumes `ktsu.Semantics.Paths/Strings/
+ Quantities` as NuGet packages, so adding a `ktsu.Semantics.Color` reference fits its existing pattern.
+
+### The latent bug being fixed
+
+`RgbColor.FromHex` parses sRGB hex bytes directly into a struct documented as **linear** RGB, with no
+gamma decode. `ColorMath.RgbToOklab` (whose matrix assumes **linear** input) then consumes those
+sRGB-valued numbers. Two consequences:
+
+- **Display happened to be correct**: ImGui interprets `ImColor` values as straight sRGB, and the
+ pipeline passed sRGB values through unchanged, so rendered colors looked right.
+- **All perceptual math was wrong**: Oklab distance, gradients, WCAG relative luminance, contrast
+ ratios, and accessibility matching were computed on sRGB-valued numbers instead of linear ones.
+
+The fix makes the canonical representation truly linear and confines gamma conversion to explicit type
+boundaries. **Displayed palette colors do not change** (hex→linear→sRGB round-trips to identity);
+**perceptual computations become correct**.
+
+## Decisions (resolved during brainstorming)
+
+1. **Channel storage:** `double` internally, `float` interop helpers. Not generic over `INumber`.
+2. **API shape:** hybrid — one canonical `Color` (linear RGB + alpha) is the type consumers pass
+ around; lightweight space structs (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`) exist as conversion
+ targets / math intermediates.
+3. **v1 scope:** the migration-complete set **plus** `Hsv` and a `NamedColors` (CSS/X11) table.
+ Deferred to later: `Lab`, `Xyz`, `Cmyk`, spectral `Wavelength→Color`.
+4. **Canonical = linear RGB.** ImGui interop goes through an explicit sRGB-encoding boundary, never a
+ raw linear cast.
+5. **ImGui ergonomics:** `ToImColor()`/`FromImColor()` (↔ `ImColor`) and
+ `ToImGuiVector4()`/`FromImGuiVector4()` (↔ a strong `ImGuiVector4` carrying the sRGB-encoded value).
+ `ImGuiVector4` **implicitly widens to `System.Numerics.Vector4`**, so it drops into any ImGui call
+ expecting a `Vector4` with no ceremony.
+6. **The ImGui adapter ships from the ImGuiApp repo, not the Semantics repo.** The Semantics repo is
+ deliberately dependency-light (Polyfill, PreciseNumber); it must not take a `Hexa.NET.ImGui`
+ dependency. The adapter is a new `ktsu.ImGui.Color` package in ImGuiApp (alongside `ktsu.ImGui.Styler`
+ in the `ktsu.ImGui.*` family). The Semantics `Color` core stays ImGui-agnostic and exposes the
+ primitive `ToSrgbVector4()`; the adapter builds the ImGui surface on top.
+7. **No shims.** `PerceptualColor → Color` is an accepted breaking change to ThemeProvider's public
+ API; the only consumer (`ktsu.ImGui.Styler`) is migrated in lockstep.
+
+## Architecture
+
+### Package layout
+
+| Package | Repo | Targets | Depends on | Responsibility |
+|---|---|---|---|---|
+| `Semantics.Color` | **Semantics** | netstandard2.0/2.1 + net8–net10 | (none beyond BCL `System.Numerics`) | Color value types + color science. Hand-written, style mirrors `Semantics.Music`. |
+| `ktsu.ImGui.Color` | **ImGuiApp** | net8–net10 (match ImGui consumers) | `ktsu.Semantics.Color`, `Hexa.NET.ImGui` (2.2.9) | Thin adapter: `ToImColor()`/`FromImColor()`/`ToImGuiVector4()`/`FromImGuiVector4()` + the `ImGuiVector4` strong type. Mirrors the `ThemeProvider` / `ThemeProvider.ImGui` split. |
+
+Only `Semantics.Color` is added to `Semantics.sln`; the adapter lives in the ImGuiApp monorepo.
+`Semantics.Color` can target the wide matrix because it is float/double math with no `INumber`
+requirement, and it takes **no** UI-framework dependency.
+
+> **This spec's implementation plan (`docs/superpowers/plans/`) covers `Semantics.Color` only** — the
+> first shippable unit, in this repo. The `ktsu.ImGui.Color` adapter + `ktsu.ImGui.Styler` migration
+> (ImGuiApp repo) and the `ktsu.ThemeProvider` migration (ThemeProvider repo) are follow-on plans
+> authored in their own repos once `ktsu.Semantics.Color` is published.
+
+### Core type: `Color`
+
+```csharp
+public readonly record struct Color(double R, double G, double B, double A);
+```
+
+Stores **linear** RGB + alpha, each `0..1`. Alpha is not gamma-encoded.
+
+- **Factories:** `FromSrgb`, `FromLinear`, `FromHex` (sRGB-assumed; `#RGB`, `#RRGGBB`, `#RRGGBBAA`),
+ `FromBytes` (sRGB), `FromOklab`, `FromOklch`, `FromHsl`, `FromHsv`.
+- **Conversions out:** `ToSrgb()`, `ToHex()`, `ToBytes()`, `ToOklab()`, `ToOklch()`, `ToHsl()`,
+ `ToHsv()`, `WithAlpha(double)`.
+- **Interop primitives:** `ToSrgbVector4()` (float, sRGB — the value ImGui wants),
+ `ToLinearVector4()` (float, linear), plus `Vector3` variants dropping alpha.
+- **Operations (ported from `ColorMath`):** `RelativeLuminance` (WCAG, on linear), `ContrastRatio(Color)`,
+ `AccessibilityLevelAgainst(Color background, bool largeText = false)`,
+ `AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)`,
+ `DistanceTo(Color)` (Oklab Euclidean), `MixOklab(Color, double t)`, `Lerp(Color, double t)` (linear),
+ `Gradient(Color to, int steps)` (perceptual, Oklab).
+
+### Space structs (math intermediates)
+
+`readonly record struct` each, with conversions to/from `Color`:
+
+- `Srgb(double R, double G, double B)` — the **only** struct that crosses the gamma boundary
+ (`Srgb`↔`Color`). Gamma transfer functions live here.
+- `Hsl(double H, double S, double L)`, `Hsv(double H, double S, double V)` — derived from sRGB.
+- `Oklab(double L, double A, double B)`, `Oklch(double L, double C, double H)` — derived from linear.
+
+`Color` is always linear; consumers never accidentally feed sRGB into perceptual math.
+
+### Supporting types
+
+- `NamedColors` — static class exposing CSS/X11 named colors as `Color`.
+- `AccessibilityLevel` — enum `{ Fail, AA, AAA }` (moved from ThemeProvider).
+
+### ImGui adapter (`ktsu.ImGui.Color`, ImGuiApp repo)
+
+```csharp
+// Strong type: an sRGB-encoded, ImGui-ready colour vector. Lives in ktsu.ImGui.Color.
+// (No Hexa.NET.ImGui dependency needed for the type itself — it is just a Vector4 wrapper.)
+public readonly record struct ImGuiVector4(float X, float Y, float Z, float W)
+{
+ public ImGuiVector4(Vector4 v) : this(v.X, v.Y, v.Z, v.W) { }
+ public static implicit operator Vector4(ImGuiVector4 v) => new(v.X, v.Y, v.Z, v.W);
+ public Vector4 ToVector4();
+}
+
+public static class ColorImGuiExtensions
+{
+ public static ImColor ToImColor(this Color color); // → ImColor (sRGB)
+ public static Color FromImColor(this ImColor color); // treats ImColor as sRGB
+
+ public static ImGuiVector4 ToImGuiVector4(this Color color); // → strong sRGB vector (widens to Vector4)
+ public static Color FromImGuiVector4(ImGuiVector4 srgb); // from the strong type
+ public static Color FromImGuiVector4(Vector4 srgb); // ingest a raw ImGui Vector4 as sRGB
+}
+```
+
+`ToImColor()` / `ToImGuiVector4()` are the "nobody needs to remember gamma" entry points requested in
+brainstorming — both emit the sRGB-encoded value ImGui expects, so callers never reason about gamma.
+`ImGuiVector4` widens implicitly to `System.Numerics.Vector4`. `FromImGuiVector4` is overloaded so the
+raw `Vector4` you read back from ImGui (e.g. `ImColor.Value`) can be ingested directly as sRGB.
+
+## Migration
+
+### ThemeProvider (breaking; major version bump)
+
+- Delete `RgbColor`, `SRgbColor`, `OklabColor`, `PerceptualColor`, `ColorMath`, `AccessibilityLevel`;
+ add a dependency on `Semantics.Color`.
+- **`PerceptualColor` → `Color`.** Perceptual properties (`Hue`/`Chroma`/`Lightness`) are obtained via
+ `.ToOklch()`/`.ToOklab()` instead of cached fields. The public palette type becomes
+ `ImmutableDictionary`.
+- The ~40 theme files switch `RgbColor.FromHex(...)` / `PerceptualColor.FromRgb(hex)` to
+ `Color.FromHex(...)` (mechanical).
+- Retained semantic-mapping layer: `SemanticColorMapper`, `SemanticMeaning`, `SemanticColorRequest`,
+ `IPaletteMapper`, `ISemanticTheme`, `ThemeRegistry`, `Priority`. `ColorRange` is rewritten in terms
+ of `Color`/`Oklch` (its perceptual-range logic moves onto the new types; it stays in ThemeProvider as
+ semantic-layer code).
+- `ThemeProvider.ImGui/ImGuiPaletteMapper` updated to the new types.
+
+### ktsu.ImGui.Color (new adapter, ImGuiApp repo)
+
+- New package `ktsu.ImGui.Color` providing the `ImGuiVector4` strong type and the
+ `ToImColor`/`FromImColor`/`ToImGuiVector4`/`FromImGuiVector4` extensions over `Semantics.Color.Color`.
+ References `ktsu.Semantics.Color` + `Hexa.NET.ImGui` (2.2.9). This is the only place
+ `Hexa.NET.ImGui` meets `Semantics.Color`.
+
+### ktsu.ImGui.Styler (ImGuiApp repo)
+
+- The static `Color` class keeps its ImGui-facing API surface (`FromHex`, `FromRGB/RGBA`,
+ `FromHSL/HSLA`, `FromVector`) and the `Palette`, but delegates **all** conversion to `Semantics.Color`
+ and emits `ImColor` via the `ktsu.ImGui.Color` adapter (`ToImColor()`). The hand-rolled `HueToRGB`
+ is deleted.
+- `FromPerceptualColor(PerceptualColor)` → `FromColor(Color)`. The `ImGuiStylerDemo` example (in
+ ImGuiApp `examples/`) is updated to match. No `[Obsolete]` shim (per "ship immediately").
+
+## Testing (MSTest, in `Semantics.Test`)
+
+- **Round-trips:** `Srgb↔Color` (linear) identity within tolerance; `Color↔hex`; `Color↔Oklab`;
+ `Color↔Oklch`; `Color↔Hsl`; `Color↔Hsv`.
+- **Known values:** black/white contrast ratio = 21:1; relative luminance of pure primaries; Oklab of
+ reference colors matches Björn Ottosson's published values; gradient endpoints equal the inputs.
+- **Gamma regression (proves the fix):** `hex → Color → hex` is stable (display unchanged); Oklab
+ computed from true-linear differs measurably from the old "sRGB-as-linear" computation.
+- **Accessibility:** `AdjustForContrast` reaches the requested `AccessibilityLevel` for representative
+ fg/bg pairs.
+- **NamedColors:** spot-check a handful against known hex values.
+
+## Shipping order
+
+1. **Semantics repo:** build and publish `ktsu.Semantics.Color` with tests. *(This spec's plan.)*
+2. **ThemeProvider repo:** reference `ktsu.Semantics.Color`, migrate (`PerceptualColor → Color`), ship
+ (major bump).
+3. **ImGuiApp repo:** add the `ktsu.ImGui.Color` adapter, then migrate `ktsu.ImGui.Styler` to the new
+ ThemeProvider + `ktsu.Semantics.Color` + `ktsu.ImGui.Color`, update the demo, ship.
+
+Each repo is independently buildable in that order. **Cross-repo caveat:** because these are separate
+repositories/feeds, `ktsu.Semantics.Color` must be published before ThemeProvider/ImGuiApp can
+reference it via NuGet; during development use temporary local `ProjectReference`s.
+
+## Out of scope (deferred)
+
+`Lab`, `Xyz`, `Cmyk` color spaces; spectral `Wavelength→Color` (would bridge to the photometry
+quantities); a richer named-palette system; any color-picker UI.
+
+## Risks
+
+- **Cross-repo coordination:** three repos, three CI pipelines, ordered publishing. Mitigated by the
+ shipping order and local ProjectReferences during dev.
+- **Perceptual-math behavior change:** themed *derived* colors (nearest-match, gradients, contrast
+ decisions) will shift because the math is now correct. This is intended; base palette colors are
+ unaffected. Tests pin the display-stability and document the math change.