# `Color.Palette.Contrast`

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](https://leonardocolor.io/).
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

* `:wcag` (default) — WCAG 2.x contrast ratio, via
  `Color.Contrast.wcag_ratio/2`. Targets typically in
  `[1.0, 21.0]`.

* `:apca` — APCA W3 0.1.9 Lc value, via `Color.Contrast.apca/2`.
  Targets typically in `[15, 108]` (absolute value).

# `stop`

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

# `t`

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

# `fetch`

```elixir
@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

* `palette` is a `Color.Palette.Contrast` struct.

* `target` is the contrast target to look up.

### 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`

```elixir
@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

* `palette` is a `Color.Palette.Contrast` struct.

* `working_space` is an RGB working-space atom. Defaults to
  `:SRGB`.

### 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?`

```elixir
@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

* `palette` is a `Color.Palette.Contrast` struct.

* `working_space` is an RGB working-space atom. Defaults to
  `:SRGB`.

### 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`

```elixir
@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`

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

Emits the palette as a W3C [Design Tokens Community Group](https://www.designtokens.org/)
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

* `palette` is a `Color.Palette.Contrast` struct.

### 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"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
