# `Color.Palette.ContrastScale`

A **contrast-constrained tonal scale** — the hybrid algorithm
described by Matt Ström-Awn in
[*Generating colour palettes with math*](https://mattstromawn.com/writing/generating-color-palettes/).

The scale is generated so that any two stops whose labels
differ by at least `apart` are **guaranteed** to satisfy a
minimum contrast ratio against each other. For example, with
the default `guarantee: {4.5, 500}`, stops 50 and 600 — or any
other pair ≥ 500 apart — will contrast ≥ 4.5 : 1 against each
other against the given background.

Unlike `Color.Palette.Tonal`, which produces visually-even
lightness steps with no contrast guarantee, and
`Color.Palette.Contrast`, which places stops at specific
contrast targets with no between-stop invariant, this
algorithm makes **pairwise contrast a structural property of
the scale**.

## Algorithm

Let `t = apart / (max_label − min_label)` — the fraction of
the scale that the `apart` distance spans. For each stop with
normalised position `p ∈ [0, 1]`:

1. Compute the stop's target **contrast against the
   background**: `C(p) = ratio ^ (p / t)`. This places the
   lightest stop at contrast 1 (equal to background) and the
   darkest at `ratio ^ (1/t)`.

2. Binary-search Oklch lightness for a colour that achieves
   `C(p)` against the background, holding the seed's hue and
   chroma approximately constant.

The pairwise invariant falls out of this: for any two stops
`i, j` with `|p_j − p_i| ≥ t`, `contrast(i, j) = C_j / C_i ≥
ratio`.

Hue drift (`hue_drift: true`) applies the paper's
Bezold-Brücke compensation: `H(p) = H_base + 5 · (1 − p)`.

## When to reach for this

* You want a Tailwind-style numeric scale *and* you want
  pairwise contrast guarantees built in.

* You're building an accessible design system and you don't
  want to audit individual pairs after the fact.

* You want light and dark modes to follow the same contrast
  rules by construction — generate the same seed against
  `background: "white"` and `background: "black"` and the
  invariant holds on both.

For component states tied to specific ratios (resting 3 : 1,
focus 4.5 : 1, disabled 1.3 : 1), `Color.Palette.Contrast` is
still the right tool. For purely visual scales with no
accessibility requirement, `Color.Palette.Tonal` produces
smoother-looking results.

## Example

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> Map.keys(palette.stops) |> Enum.sort()
    [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> {ratio, apart} = palette.guarantee
    iex> {ratio, apart}
    {4.5, 500}

# `guarantee`

```elixir
@type guarantee() :: {number(), number()}
```

# `stop_label`

```elixir
@type stop_label() :: integer() | atom() | binary()
```

# `t`

```elixir
@type t() :: %Color.Palette.ContrastScale{
  achieved: %{required(stop_label()) =&gt; number()},
  background: Color.SRGB.t(),
  guarantee: guarantee(),
  metric: :wcag | :apca,
  name: binary() | nil,
  options: keyword(),
  seed: Color.SRGB.t(),
  seed_stop: stop_label(),
  stops: %{required(stop_label()) =&gt; Color.SRGB.t()}
}
```

# `fetch`

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

Fetches the colour at a given stop label.

### Arguments

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

* `label` is the stop label.

### Returns

* `{:ok, color}` or `:error` for unknown labels.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> {:ok, _} = Color.Palette.ContrastScale.fetch(palette, 500)
    iex> Color.Palette.ContrastScale.fetch(palette, :missing)
    :error

# `gamut_report`

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

Returns a detailed per-stop gamut report. See
`Color.Palette.Tonal.gamut_report/2` for the shape.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> %{in_gamut?: true} = Color.Palette.ContrastScale.gamut_report(palette, :SRGB)

# `in_gamut?`

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

Returns `true` when every stop in the palette is inside the
given RGB working space. See `Color.Palette.Tonal.in_gamut?/2`
for details.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> Color.Palette.ContrastScale.in_gamut?(palette, :SRGB)
    true

# `labels`

```elixir
@spec labels(t()) :: [stop_label()]
```

Returns the list of stop labels in the order they were
configured.

### Arguments

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

### Returns

* A list of stop labels.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
    iex> Color.Palette.ContrastScale.labels(palette)
    [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]

# `new`

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

Generates a contrast-constrained tonal scale.

See the moduledoc for the algorithm.

### Arguments

* `seed` is anything accepted by `Color.new/1`.

### Options

* `:stops` is the list of numeric stop labels. Default
  `[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]`.
  The labels **must be numeric** — the contrast invariant is
  defined in terms of label distance.

* `:guarantee` is a `{ratio, apart}` tuple specifying the
  minimum contrast ratio that any two stops `apart` label units
  apart must satisfy. Default `{4.5, 500}` (WCAG AA body text).

* `:background` is the colour against which contrast is
  measured. Default `"white"`.

* `:metric` is `:wcag` (default) or `:apca`. For `:apca`,
  `ratio` is interpreted as an APCA Lc value.

* `:hue_drift` enables the paper's Bezold-Brücke
  compensation. Default `false`.

* `:gamut` is the gamut to map each stop into. Default
  `:SRGB`.

* `:name` is an optional label.

### Returns

* A `Color.Palette.ContrastScale` struct.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
    iex> palette.name
    "blue"
    iex> palette.seed_stop in Map.keys(palette.stops)
    true

# `to_css`

```elixir
@spec to_css(
  t(),
  keyword()
) :: binary()
```

Emits the palette as a block of **CSS custom properties**. See
`Color.Palette.Tonal.to_css/2` for option details.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
    iex> css = Color.Palette.ContrastScale.to_css(palette)
    iex> String.contains?(css, "--blue-500:")
    true

# `to_tailwind`

```elixir
@spec to_tailwind(
  t(),
  keyword()
) :: binary()
```

Emits the palette as a **Tailwind CSS v4 `@theme` block**. See
`Color.Palette.Tonal.to_tailwind/2` for option details.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
    iex> tw = Color.Palette.ContrastScale.to_tailwind(palette)
    iex> String.contains?(tw, "--color-blue-500:")
    true

# `to_tokens`

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

Emits the palette as a W3C [DTCG](https://www.designtokens.org/tr/2025.10/color/)
color-token group, with the achieved contrast ratio for each
stop recorded in `$extensions.color.achieved`.

### Arguments

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

### Options

* `:space` is the colour space for emitted stop values.
  Default `Color.Oklch`.

* `:name` overrides the group name. Defaults to the palette's
  `:name` field, or `"contrast_scale"` if unset.

### Returns

* A map shaped as `%{"<name>" => %{"<label>" => token, ...}}`.

### Examples

    iex> palette = Color.Palette.ContrastScale.new("#3b82f6", name: "blue")
    iex> tokens = Color.Palette.ContrastScale.to_tokens(palette)
    iex> tokens["blue"]["500"]["$type"]
    "color"

---

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