From baf8d6ed0ed0d9d0ea2d20ec7a8eaa2e57d94d71 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 15:21:50 +1000 Subject: [PATCH 01/11] feat(color): scaffold Semantics.Color with canonical linear Color type --- Semantics.Color/Color.cs | 46 +++++++++++++++++++++++ Semantics.Color/Semantics.Color.csproj | 13 +++++++ Semantics.Test/Colors/ColorCoreTests.cs | 49 +++++++++++++++++++++++++ Semantics.Test/Semantics.Test.csproj | 1 + Semantics.sln | 14 +++++++ 5 files changed, 123 insertions(+) create mode 100644 Semantics.Color/Color.cs create mode 100644 Semantics.Color/Semantics.Color.csproj create mode 100644 Semantics.Test/Colors/ColorCoreTests.cs diff --git a/Semantics.Color/Color.cs b/Semantics.Color/Color.cs new file mode 100644 index 0000000..14fda9b --- /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/Semantics.Color.csproj b/Semantics.Color/Semantics.Color.csproj new file mode 100644 index 0000000..2c7afde --- /dev/null +++ b/Semantics.Color/Semantics.Color.csproj @@ -0,0 +1,13 @@ + + + + + + net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 + + + + + + + diff --git a/Semantics.Test/Colors/ColorCoreTests.cs b/Semantics.Test/Colors/ColorCoreTests.cs new file mode 100644 index 0000000..7bb31f9 --- /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); + } +} \ No newline at end of file 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 From a762dc90cfb46ac8c0680034141cd97b3a4fccbb Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 15:27:53 +1000 Subject: [PATCH 02/11] style(color): strip UTF-8 BOM and add final newline (editorconfig) --- Semantics.Color/Color.cs | 2 +- Semantics.Test/Colors/ColorCoreTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Semantics.Color/Color.cs b/Semantics.Color/Color.cs index 14fda9b..2f7e418 100644 --- a/Semantics.Color/Color.cs +++ b/Semantics.Color/Color.cs @@ -1,4 +1,4 @@ -// Copyright (c) ktsu.dev +// Copyright (c) ktsu.dev // All rights reserved. // Licensed under the MIT license. diff --git a/Semantics.Test/Colors/ColorCoreTests.cs b/Semantics.Test/Colors/ColorCoreTests.cs index 7bb31f9..93adb14 100644 --- a/Semantics.Test/Colors/ColorCoreTests.cs +++ b/Semantics.Test/Colors/ColorCoreTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) ktsu.dev +// Copyright (c) ktsu.dev // All rights reserved. // Licensed under the MIT license. @@ -46,4 +46,4 @@ public void ToLinearVector4_ReturnsFloatChannels() Assert.AreEqual(0.1f, v.X, 1e-6f); Assert.AreEqual(0.4f, v.W, 1e-6f); } -} \ No newline at end of file +} From 162f6f89fefb4016d4cbba3df05f36bbf181d8ec Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 15:27:56 +1000 Subject: [PATCH 03/11] docs(color): add semantic-domains roadmap, Semantics.Color spec and plan --- docs/roadmap-semantic-domains.md | 235 +++ .../plans/2026-06-29-semantics-color.md | 1721 +++++++++++++++++ .../2026-06-29-semantics-color-design.md | 215 ++ 3 files changed, 2171 insertions(+) create mode 100644 docs/roadmap-semantic-domains.md create mode 100644 docs/superpowers/plans/2026-06-29-semantics-color.md create mode 100644 docs/superpowers/specs/2026-06-29-semantics-color-design.md 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..a6532e8 --- /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. +- If a round-trip test fails by a hair above tolerance on a specific TFM, do not loosen the tolerance — investigate; the conversions are exact-by-construction and should round-trip to ~1e-9 in `double`. 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. From 2caa52f1b03882186793fdb8c86e642bd1f2295f Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 16:03:34 +1000 Subject: [PATCH 04/11] feat(color): add Srgb space and gamma-correct sRGB<->linear boundary --- Semantics.Color/Color.Conversions.cs | 44 ++++++++++++++++++ Semantics.Color/Srgb.cs | 34 ++++++++++++++ Semantics.Test/Colors/SrgbConversionTests.cs | 49 ++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 Semantics.Color/Color.Conversions.cs create mode 100644 Semantics.Color/Srgb.cs create mode 100644 Semantics.Test/Colors/SrgbConversionTests.cs diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs new file mode 100644 index 0000000..00c4445 --- /dev/null +++ b/Semantics.Color/Color.Conversions.cs @@ -0,0 +1,44 @@ +// 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); + } +} 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/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); + } +} From 50dbe941f834be7ea90784ff7ade26a6be9f98d6 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 16:14:45 +1000 Subject: [PATCH 05/11] feat(color): add hex and byte conversions (sRGB-interpreted) --- Semantics.Color/Color.Conversions.cs | 66 +++++++++++++++++++++ Semantics.Test/Colors/HexConversionTests.cs | 63 ++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 Semantics.Test/Colors/HexConversionTests.cs diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs index 00c4445..4fba901 100644 --- a/Semantics.Color/Color.Conversions.cs +++ b/Semantics.Color/Color.Conversions.cs @@ -4,6 +4,8 @@ namespace ktsu.Semantics.Color; +using System; +using System.Globalization; using System.Numerics; public readonly partial record struct Color @@ -41,4 +43,68 @@ 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) => + byte.Parse(hex.AsSpan(index, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + private static byte ToByte(double channel) + { + double scaled = Math.Round(Clamp01(channel) * 255.0); + return (byte)scaled; + } } 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")); +} From c3453a5884b31e113bea2d064b72fab167496adc Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 16:57:33 +1000 Subject: [PATCH 06/11] feat(color): add Oklab and Oklch perceptual spaces --- Semantics.Color/Color.Conversions.cs | 20 +++++ Semantics.Color/Oklab.cs | 82 +++++++++++++++++++ Semantics.Color/Oklch.cs | 24 ++++++ Semantics.Test/Colors/OklabConversionTests.cs | 51 ++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 Semantics.Color/Oklab.cs create mode 100644 Semantics.Color/Oklch.cs create mode 100644 Semantics.Test/Colors/OklabConversionTests.cs diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs index 4fba901..1f026bb 100644 --- a/Semantics.Color/Color.Conversions.cs +++ b/Semantics.Color/Color.Conversions.cs @@ -102,6 +102,26 @@ public static Color FromBytes(byte r, byte g, byte b, byte a = 255) => private static byte ParseByte(string hex, int index) => byte.Parse(hex.AsSpan(index, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + /// 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); + private static byte ToByte(double channel) { double scaled = Math.Round(Clamp01(channel) * 255.0); 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.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); + } +} From 5ee401a89ea96362b85bbc0d692450839be228d5 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 17:09:40 +1000 Subject: [PATCH 07/11] feat(color): add HSL and HSV conversions --- Semantics.Color/Color.Conversions.cs | 20 ++++ Semantics.Color/Hsl.cs | 111 ++++++++++++++++++ Semantics.Color/Hsv.cs | 52 ++++++++ .../Colors/HslHsvConversionTests.cs | 49 ++++++++ 4 files changed, 232 insertions(+) create mode 100644 Semantics.Color/Hsl.cs create mode 100644 Semantics.Color/Hsv.cs create mode 100644 Semantics.Test/Colors/HslHsvConversionTests.cs diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs index 1f026bb..37177c7 100644 --- a/Semantics.Color/Color.Conversions.cs +++ b/Semantics.Color/Color.Conversions.cs @@ -122,6 +122,26 @@ private static byte ParseByte(string hex, int index) => /// 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); 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.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); + } +} From 119ff7daeea85a22db8eaf0925e8b947b040ec18 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 17:20:42 +1000 Subject: [PATCH 08/11] feat(color): add WCAG luminance, contrast, and accessibility adjustment --- Semantics.Color/AccessibilityLevel.cs | 18 ++++ Semantics.Color/Color.Operations.cs | 104 ++++++++++++++++++++ Semantics.Test/Colors/AccessibilityTests.cs | 45 +++++++++ 3 files changed, 167 insertions(+) create mode 100644 Semantics.Color/AccessibilityLevel.cs create mode 100644 Semantics.Color/Color.Operations.cs create mode 100644 Semantics.Test/Colors/AccessibilityTests.cs 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.Operations.cs b/Semantics.Color/Color.Operations.cs new file mode 100644 index 0000000..a32c6ce --- /dev/null +++ b/Semantics.Color/Color.Operations.cs @@ -0,0 +1,104 @@ +// 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(); + 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(); + } +} 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)}"); + } +} From c268d63f9da2231c31a36588c2f499d857f53532 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 17:51:22 +1000 Subject: [PATCH 09/11] feat(color): add Oklab mix, lerp, distance, and gradient --- Semantics.Color/Color.Operations.cs | 67 ++++++++++++++++++++ Semantics.Test/Colors/ColorOperationTests.cs | 63 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 Semantics.Test/Colors/ColorOperationTests.cs diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs index a32c6ce..9273eb3 100644 --- a/Semantics.Color/Color.Operations.cs +++ b/Semantics.Color/Color.Operations.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Color; using System; +using System.Collections.Generic; public readonly partial record struct Color { @@ -101,4 +102,70 @@ public Color AdjustForContrast(Color background, AccessibilityLevel target, bool 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.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)); +} From 66601717a80950a273c29baa510647f821d6d3d3 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 19:45:10 +1000 Subject: [PATCH 10/11] feat(color): add NamedColors and gamma-regression tests Cross-task netstandard2.0 fixes in Color.Conversions.cs and Semantics.Color.csproj: - Replace byte.Parse(AsSpan) with Convert.ToByte(Substring, 16) (no CA1846, works on all TFMs) - Add System.Numerics.Vectors package reference for netstandard2.0 (Vector3/Vector4) - Add System.Numerics.Vectors version to Directory.Packages.props All 5 TFMs (net10.0, net9.0, net8.0, netstandard2.1, netstandard2.0) build clean; 39 Color tests pass. --- Directory.Packages.props | 1 + Semantics.Color/Color.Conversions.cs | 3 +- Semantics.Color/NamedColors.cs | 79 +++++++++++++++++++ Semantics.Color/Semantics.Color.csproj | 1 + Semantics.Test/Colors/GammaRegressionTests.cs | 33 ++++++++ Semantics.Test/Colors/NamedColorsTests.cs | 31 ++++++++ 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 Semantics.Color/NamedColors.cs create mode 100644 Semantics.Test/Colors/GammaRegressionTests.cs create mode 100644 Semantics.Test/Colors/NamedColorsTests.cs 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/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs index 37177c7..de25aed 100644 --- a/Semantics.Color/Color.Conversions.cs +++ b/Semantics.Color/Color.Conversions.cs @@ -5,7 +5,6 @@ namespace ktsu.Semantics.Color; using System; -using System.Globalization; using System.Numerics; public readonly partial record struct Color @@ -100,7 +99,7 @@ public static Color FromBytes(byte r, byte g, byte b, byte a = 255) => } private static byte ParseByte(string hex, int index) => - byte.Parse(hex.AsSpan(index, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + Convert.ToByte(hex.Substring(index, 2), 16); /// Converts this linear color to . /// The Oklab equivalent. 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/Semantics.Color.csproj b/Semantics.Color/Semantics.Color.csproj index 2c7afde..3b4c90a 100644 --- a/Semantics.Color/Semantics.Color.csproj +++ b/Semantics.Color/Semantics.Color.csproj @@ -8,6 +8,7 @@ + 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/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 _)); +} From 84b258232cef4170aa1c22c289f29142b9d01bcd Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 29 Jun 2026 19:50:09 +1000 Subject: [PATCH 11/11] docs(color): correct Oklab round-trip tolerance note in plan --- docs/superpowers/plans/2026-06-29-semantics-color.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-29-semantics-color.md b/docs/superpowers/plans/2026-06-29-semantics-color.md index a6532e8..f85d98b 100644 --- a/docs/superpowers/plans/2026-06-29-semantics-color.md +++ b/docs/superpowers/plans/2026-06-29-semantics-color.md @@ -1718,4 +1718,4 @@ git commit -m "feat(color): add NamedColors and gamma-regression tests" - `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. -- If a round-trip test fails by a hair above tolerance on a specific TFM, do not loosen the tolerance — investigate; the conversions are exact-by-construction and should round-trip to ~1e-9 in `double`. +- 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.