Color.Palette.Contrast (Color v0.11.0)

Copy Markdown

A contrast-targeted palette — shades of a seed colour chosen so each stop hits a specific contrast ratio against a fixed background.

This is the algorithm behind Adobe Leonardo. Unlike Color.Palette.Tonal, which produces visually even lightness steps, this palette produces contrast-wise even steps. Each stop in the result satisfies the WCAG or APCA contrast ratio you asked for — exactly — against the background you specified.

Use it when you need:

  • Component states (resting, hover, active, focus, disabled) that all meet a specific contrast requirement.

  • Text shades guaranteed to pass AA (4.5:1) or AAA (7:1) on a known surface.

  • APCA-driven text hierarchies, with :metric: :apca and targets in the 60–90 Lc range.

Algorithm

  1. Convert the seed to Oklch. Keep its hue and chroma.

  2. For each target contrast ratio, binary search over Oklch lightness L ∈ [0, 1] for a colour whose contrast against the background matches the target. Contrast is monotonic in lightness (for a fixed hue / chroma), so binary search converges in ~20 iterations to sub-0.01 precision.

  3. Gamut-map each candidate into sRGB with Color.Gamut.

  4. If the search direction (lighter vs darker than background) can't reach the target, the stop is marked :unreachable. This happens when the target ratio is higher than what the seed's hue and chroma can achieve against the given background.

Metrics

Summary

Functions

Fetches the colour for a given target contrast from a palette.

Returns a detailed gamut report on a contrast palette.

Returns true when every reachable stop in the palette is inside the given RGB working space.

Generates a contrast-targeted palette.

Emits the palette as a W3C Design Tokens Community Group color-token group.

Types

stop()

@type stop() :: %{
  target: number(),
  achieved: number(),
  color: Color.SRGB.t() | :unreachable
}

t()

@type t() :: %Color.Palette.Contrast{
  background: Color.SRGB.t(),
  metric: :wcag | :apca,
  name: binary() | nil,
  options: keyword(),
  seed: Color.SRGB.t(),
  stops: [stop()]
}

Functions

fetch(contrast, target)

@spec fetch(t(), number()) :: {:ok, Color.SRGB.t()} | :error

Fetches the colour for a given target contrast from a palette.

If the target was not in the original :targets list, or no lightness was found that satisfied it, returns :error.

Arguments

Returns

  • {:ok, color} on success, :error otherwise.

Examples

iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
iex> {:ok, _color} = Color.Palette.Contrast.fetch(palette, 4.5)
iex> Color.Palette.Contrast.fetch(palette, 99.0)
:error

gamut_report(contrast, working_space \\ :SRGB)

@spec gamut_report(t(), Color.Types.working_space()) :: map()

Returns a detailed gamut report on a contrast palette.

Each reachable stop becomes a %{target, color, in_gamut?} entry; unreachable stops become %{target, color: :unreachable}.

Arguments

Returns

  • A map with :working_space, :in_gamut?, :stops, and :out_of_gamut.

Examples

iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
iex> %{in_gamut?: true} = Color.Palette.Contrast.gamut_report(palette, :SRGB)

in_gamut?(contrast, working_space \\ :SRGB)

@spec in_gamut?(t(), Color.Types.working_space()) :: boolean()

Returns true when every reachable stop in the palette is inside the given RGB working space.

Unreachable stops (:unreachable sentinel) are ignored — they have no colour to check.

Arguments

Returns

  • A boolean.

Examples

iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
iex> Color.Palette.Contrast.in_gamut?(palette, :SRGB)
true

new(seed, options \\ [])

@spec new(
  Color.input(),
  keyword()
) :: t()

Generates a contrast-targeted palette.

Arguments

  • seed is anything accepted by Color.new/1. Its hue and chroma are preserved; lightness is swept to hit each target.

Options

  • :background is the colour to measure contrast against. Defaults to "white".

  • :targets is a list of target contrast values. Defaults to [1.25, 1.5, 2.0, 3.0, 4.5, 7.0, 10.0, 15.0] for WCAG or [15, 30, 45, 60, 75, 90] for APCA.

  • :metric is :wcag (default) or :apca.

  • :gamut is the working space to gamut-map each stop into, default :SRGB.

  • :name is an optional string label.

Returns

  • A Color.Palette.Contrast struct. Each entry in :stops has :target, :achieved, and :color fields. :color is :unreachable if no lightness produces the requested contrast.

Examples

iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [3.0, 4.5, 7.0])
iex> length(palette.stops)
3
iex> Enum.all?(palette.stops, & &1.color != :unreachable)
true

to_tokens(palette, options \\ [])

@spec to_tokens(
  t(),
  keyword()
) :: map()

Emits the palette as a W3C Design Tokens Community Group color-token group.

Each reachable stop becomes a DTCG color token keyed by the target contrast value (integer if it rounds to one, otherwise decimal). Unreachable stops are emitted as tokens whose $value is null with an $extensions.color.reason field explaining the exclusion — tools that filter on $value presence will skip them cleanly.

Arguments

Options

  • :space is the colour space for emitted stop values. Any module accepted by Color.convert/2. Default Color.Oklch.

  • :name overrides the group name. Defaults to the palette's :name field, or "contrast" if unset.

Returns

  • A map shaped as %{"<name>" => %{"<target>" => token, ...}}.

Examples

iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
iex> tokens = Color.Palette.Contrast.to_tokens(palette)
iex> tokens["contrast"]["4.5"]["$type"]
"color"