# `Color.Palette.Tonal`

A **tonal scale** — N shades of one hue, from light to dark —
generated from a single seed colour.

This is the algorithm behind Tailwind's `blue-50` … `blue-950`,
Radix's 12-step scales, Open Color, and the per-role tonal
palettes inside Material Design 3.

## Algorithm

1. Convert the seed to **Oklch**.

2. For each requested stop, compute a target lightness `L` by
   interpolating along a curve between `light_anchor` (the
   lightness of the lightest stop) and `dark_anchor` (the
   lightness of the darkest stop). The interpolation is in
   stop-space — equally spaced positions in the stop list map
   to equally spaced lightnesses.

3. **Damp the chroma** at the extremes. Tints near white and
   shades near black cannot carry as much chroma as a mid-tone
   of the same hue without falling out of the sRGB gamut. The
   library multiplies the seed's chroma by `sin(π · L)` to taper
   it smoothly toward zero at `L = 0` and `L = 1`.

4. Optionally apply **hue drift**. When `hue_drift: true`, the
   hue rotates slightly toward yellow (~90°) at the light end
   and toward blue (~270°) at the dark end. This matches how
   human vision perceives lightness — a phenomenon known as the
   Hunt effect — and gives the scale a more natural feel.

5. **Cap to a fraction of the gamut envelope**. When
   `:chroma_ceiling` is below `1.0`, cap each stop's chroma at
   `ceiling × max_chroma(L, H, gamut)`. This produces a more
   muted ramp that sits strictly inside the gamut boundary
   instead of hugging it. At the default `1.0` this step is a
   no-op.

6. **Snap to seed**. Find the generated stop whose lightness is
   closest to the seed's lightness and replace it with the seed
   itself. The `:seed_stop` field on the resulting struct
   records which stop received the seed. Note that when
   `:chroma_ceiling` is below `1.0` and the seed sits near the
   gamut boundary, the seed will be visibly more saturated than
   its capped neighbours — pick a wider `:gamut` instead if you
   want a smoother ramp without muting the seed.

7. **Gamut-map** each stop into the requested working space
   (default `:SRGB`) using the CSS Color 4 Oklch binary-search
   algorithm provided by `Color.Gamut.to_gamut/3`.

## Stops

By default the stops are Tailwind's `[50, 100, 200, …, 950]`,
but any list of integer or atom labels may be supplied. The
algorithm cares only about position in the list, not the label
values themselves — `["lightest", "light", "mid", "dark",
"darkest"]` works just as well.

## Example

    iex> palette = Color.Palette.Tonal.new("#3b82f6")
    iex> Map.fetch!(palette.stops, 50) |> Color.to_hex()
    "#f5f9ff"

    iex> palette = Color.Palette.Tonal.new("#3b82f6")
    iex> Map.fetch!(palette.stops, 950) |> Color.to_hex()
    "#000827"

# `stop_label`

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

# `t`

```elixir
@type t() :: %Color.Palette.Tonal{
  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.Tonal` struct.

* `label` is the stop label to look up.

### Returns

* `{:ok, color}` on success, `:error` if the label is unknown.

### Examples

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

# `gamut_report`

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

Returns a detailed per-stop gamut report.

Useful for actionable CI output: which specific stops fell
outside the target gamut and need attention.

### Arguments

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

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

### Returns

* A map with:

  * `:working_space` — the space the check was performed against.

  * `:in_gamut?` — `true` if every stop is inside, else `false`.

  * `:stops` — list of `%{label, color, in_gamut?}` maps, one
    per stop, in label order.

  * `:out_of_gamut` — list of the same map shape, filtered to
    stops that are outside the target space.

### Examples

    iex> palette = Color.Palette.Tonal.new("#3b82f6")
    iex> report = Color.Palette.Tonal.gamut_report(palette, :SRGB)
    iex> report.in_gamut?
    true
    iex> report.out_of_gamut
    []

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

Convenient for CI checks — fail the build if a palette
generated against one working space slips outside another's
gamut.

### Arguments

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

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

### Returns

* A boolean.

### Examples

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

# `labels`

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

Returns the list of stop labels in generation order.

### Arguments

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

### Returns

* A list of stop labels.

### Examples

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

# `new`

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

Generates a tonal scale from a seed colour.

See the moduledoc for a description of the algorithm and
`Color.Palette.tonal/2` for option details.

### Arguments

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

### Options

See `Color.Palette.tonal/2`.

### Returns

* A `Color.Palette.Tonal` struct.

### Examples

    iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "brand")
    iex> palette.name
    "brand"
    iex> palette.seed_stop in [400, 500]
    true

# `to_css`

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

Emits the palette as a block of **CSS custom properties**,
one per stop, keyed by the palette name.

Output is a plain binary, ready to write to a stylesheet.

### Arguments

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

### Options

* `:name` overrides the property prefix. Defaults to the
  palette's `:name` field, or `"color"` if unset.

* `:selector` is the CSS selector the properties are declared
  on. Default `":root"`.

### Returns

* A binary containing a single CSS rule.

### Examples

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

    iex> palette = Color.Palette.Tonal.new("#3b82f6")
    iex> Color.Palette.Tonal.to_css(palette, name: "brand", selector: "[data-theme]")
    ...> |> String.starts_with?("[data-theme] {")
    true

# `to_tailwind`

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

Emits the palette as a **Tailwind CSS v4 `@theme` block** —
CSS-native theme variables using the `--color-*` namespace
that Tailwind v4 expects.

### Arguments

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

### Options

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

### Returns

* A binary containing a `@theme` block ready to paste into a
  Tailwind v4 CSS file.

### Examples

    iex> palette = Color.Palette.Tonal.new("#3b82f6", name: "blue")
    iex> config = Color.Palette.Tonal.to_tailwind(palette)
    iex> String.contains?(config, "--color-blue-500:")
    true
    iex> String.contains?(config, "@theme")
    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.

The result is a nested map ready for `:json.encode/1`. Each
stop becomes a DTCG color token keyed by its label.

### Arguments

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

### Options

* `:space` is the colour space to emit components in. Any
  module accepted by `Color.convert/2`. Default `Color.Oklch`.

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

### Returns

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

### Examples

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

---

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