2.0 KiB · text History 0d6bdf0
defmodule GitGud.Badges.Oklch do
  @moduledoc """
  oklch to sRGB, and WCAG contrast.

  The themes in `assets/css/app.css` are written in oklch, which a
  browser resolves itself. A badge that leaves the browser (a PNG, an
  SVG on someone else's site) needs concrete sRGB, so the conversion
  happens here, once, at compile time (`GitGud.Badges.Palette`).

  Björn Ottosson's oklab matrices; colors outside sRGB are clipped per
  channel, which is what browsers did before CSS Color 4 gamut mapping
  and is close enough for the few saturated tokens it touches.
  """

  @type lin :: {float(), float(), float()}

  @doc "Linear sRGB channels, each clipped to 0..1, for `oklch(l% c h)`."
  @spec to_linear(number(), number(), number()) :: lin()
  def to_linear(l_pct, c, h) do
    l = l_pct / 100
    rad = h * :math.pi() / 180
    a = c * :math.cos(rad)
    b = c * :math.sin(rad)

    l_ = :math.pow(l + 0.3963377774 * a + 0.2158037573 * b, 3)
    m_ = :math.pow(l - 0.1055613458 * a - 0.0638541728 * b, 3)
    s_ = :math.pow(l - 0.0894841775 * a - 1.2914855480 * b, 3)

    {
      clip(4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_),
      clip(-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_),
      clip(-0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_)
    }
  end

  @doc "`#rrggbb` for linear channels."
  @spec hex(lin()) :: String.t()
  def hex({r, g, b}) do
    "#" <> Enum.map_join([r, g, b], &byte/1)
  end

  @doc "WCAG relative luminance of linear channels."
  @spec luminance(lin()) :: float()
  def luminance({r, g, b}), do: 0.2126 * r + 0.7152 * g + 0.0722 * b

  @doc "WCAG contrast ratio between two luminances, 1.0 to 21.0."
  @spec contrast(float(), float()) :: float()
  def contrast(y1, y2), do: (max(y1, y2) + 0.05) / (min(y1, y2) + 0.05)

  defp clip(v), do: v |> max(0.0) |> min(1.0)

  defp byte(v) do
    encoded = if v <= 0.0031308, do: 12.92 * v, else: 1.055 * :math.pow(v, 1 / 2.4) - 0.055

    (encoded * 255)
    |> round()
    |> Integer.to_string(16)
    |> String.downcase()
    |> String.pad_leading(2, "0")
  end
end