Skip to content

Commit 4f8d509

Browse files
committed
Add gamut checker for palettes
1 parent dac1448 commit 4f8d509

8 files changed

Lines changed: 522 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to this project are documented here. The format is based on
66

77
### Added
88

9+
* Palette gamut checks. `Color.Palette.in_gamut?/2` and `Color.Palette.gamut_report/2` answer "is every stop in this palette inside the chosen RGB working space?", with `gamut_report/2` returning a per-stop breakdown listing exactly which stops failed.
10+
911
* Public APIs for designer-tool integrations. `Color.Palette.Tonal.to_css/2` and `Color.Palette.ContrastScale.to_css/2` emit CSS custom-property blocks (selector and name prefix configurable). `Color.Palette.Tonal.to_tailwind/2` and `Color.Palette.ContrastScale.to_tailwind/2` emit `theme.extend.colors` fragments ready for `tailwind.config.js`. `Color.Gamut.SVG.render/1` renders a complete chromaticity-diagram SVG — projection, gamut overlays, Planckian locus, seed / palette overlays, sizing, and colour overrides all under a single keyword-list API.
1012

1113
* `Color.Gamut.Diagram` — pure-data module for chromaticity diagrams.

guides/integrations.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Everything the visualizer does is reachable programmatically. The visualizer is
1313
| Export as Tailwind config | `Color.Palette.Tonal.to_tailwind/2`, `Color.Palette.ContrastScale.to_tailwind/2` |
1414
| Export as W3C DTCG tokens | `Color.Palette.Tonal.to_tokens/2`, `Color.Palette.Theme.to_tokens/2`, `Color.Palette.Contrast.to_tokens/2`, `Color.Palette.ContrastScale.to_tokens/2` |
1515
| Encode JSON | `:json.encode/1` (Erlang built-in) |
16+
| Check if a palette is inside a gamut | `Color.Palette.in_gamut?/2`, `Color.Palette.gamut_report/2` |
1617
| Render a gamut diagram as SVG | `Color.Gamut.SVG.render/1` |
1718
| Get raw gamut geometry | `Color.Gamut.Diagram.spectral_locus/2`, `triangle/2`, `planckian_locus/2`, `chromaticity/2` |
1819
| Encode / decode individual colours as DTCG | `Color.DesignTokens.encode/2`, `decode/1` |
@@ -232,6 +233,72 @@ sRGB_triangle = Color.Gamut.Diagram.triangle(:SRGB, :uv)
232233
{:ok, blue_point} = Color.Gamut.Diagram.chromaticity("#3b82f6", :uv)
233234
```
234235

236+
## Gamut audits — CI checks for palette accessibility
237+
238+
When a palette is generated, our algorithms gamut-map every stop into the working space passed via `:gamut` (default `:SRGB`). But palettes often travel — generated against sRGB on a designer's MacBook, hand-edited by a colourist in Display P3, re-imported through a DTCG file, then deployed to users on devices that span every display gamut. A CI check that "this palette is still legal sRGB after every transformation" is exactly the kind of guard you want in a design-system pipeline.
239+
240+
`Color.Palette.in_gamut?/2` answers a single yes/no across every stop:
241+
242+
```elixir
243+
palette = Color.Palette.tonal("#3b82f6")
244+
245+
if Color.Palette.in_gamut?(palette, :SRGB) do
246+
:ok
247+
else
248+
raise "Brand palette has escaped sRGB"
249+
end
250+
```
251+
252+
Need to know **which** stops failed? Use `gamut_report/2`:
253+
254+
```elixir
255+
report = Color.Palette.gamut_report(palette, :SRGB)
256+
# %{
257+
# working_space: :SRGB,
258+
# in_gamut?: false,
259+
# stops: [%{label: 50, color: ..., in_gamut?: true}, ...],
260+
# out_of_gamut: [%{label: 700, color: ..., in_gamut?: false}, ...]
261+
# }
262+
263+
for %{label: l, color: c} <- report.out_of_gamut do
264+
IO.puts("Stop #{l} (#{Color.to_hex(c)}) is outside sRGB")
265+
end
266+
```
267+
268+
Both functions dispatch on palette type — they work uniformly across `Tonal`, `Theme`, `Contrast`, and `ContrastScale`. For `Theme`, `gamut_report/2` returns the breakdown per sub-palette under `:sub_palettes` plus a flat `:out_of_gamut` list with `:sub_palette` keys for actionable failures.
269+
270+
A complete Mix task you can wire into CI:
271+
272+
```elixir
273+
defmodule Mix.Tasks.Brand.Audit do
274+
use Mix.Task
275+
276+
@shortdoc "Fails the build if the brand palette has escaped a target gamut"
277+
278+
def run([hex | rest]) do
279+
target = case rest do
280+
[name] -> String.to_existing_atom(name)
281+
_ -> :SRGB
282+
end
283+
284+
report = Color.Palette.tonal(hex) |> Color.Palette.gamut_report(target)
285+
286+
if report.in_gamut? do
287+
Mix.shell().info("✓ Palette is fully inside #{target}")
288+
else
289+
bad = Enum.map(report.out_of_gamut, fn %{label: l, color: c} ->
290+
" - stop #{l} (#{Color.to_hex(c)})"
291+
end)
292+
293+
Mix.raise("✗ Palette has #{length(report.out_of_gamut)} stops outside #{target}:\n" <>
294+
Enum.join(bad, "\n"))
295+
end
296+
end
297+
end
298+
```
299+
300+
Run it in CI as `mix brand.audit "#3b82f6" SRGB`. Non-zero exit on failure with a clear list of which stops broke.
301+
235302
## Decoding external Design Tokens
236303

237304
If you're importing a DTCG token file from Figma, Style Dictionary, or another tool, decode individual colour tokens back into `Color.*` structs:
@@ -317,7 +384,7 @@ Run it with `mix brand.generate "#3b82f6"` and you have four coordinated artefac
317384
| A live theme editor for a customer-branded SaaS | `to_css/2` + LiveView |
318385
| A Tailwind-only site with generated brand colours | `to_tailwind/2` + a Mix task |
319386
| A documentation site for a design system | `Color.Gamut.SVG.render/1` for gamut plots + `to_tokens/2` for reference |
320-
| A CI check that a palette stays inside sRGB | `Color.Gamut.Diagram.chromaticity/2` + a triangle containment test |
387+
| A CI check that a palette stays inside sRGB | `Color.Palette.in_gamut?/2` + `gamut_report/2` |
321388
| A custom diagram renderer (PDF, PostScript, Canvas) | `Color.Gamut.Diagram.spectral_locus/2` + `triangle/2` |
322389
| Ingesting a DTCG file from Figma | `Color.DesignTokens.decode/1` |
323390

lib/color/palette.ex

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,76 @@ defmodule Color.Palette do
172172
"""
173173
@spec contrast_scale(Color.input(), keyword()) :: ContrastScale.t()
174174
defdelegate contrast_scale(seed, options \\ []), to: ContrastScale, as: :new
175+
176+
@doc """
177+
Returns `true` if every stop in the given palette is inside
178+
the chosen RGB working space.
179+
180+
Dispatches on the palette struct type, so works uniformly for
181+
`Color.Palette.Tonal`, `Color.Palette.Theme`,
182+
`Color.Palette.Contrast`, and `Color.Palette.ContrastScale`.
183+
184+
Intended primarily for CI checks — call once per palette and
185+
fail the build if the result is `false`.
186+
187+
### Arguments
188+
189+
* `palette` is any palette struct produced by this module.
190+
191+
* `working_space` is an RGB working-space atom. Defaults to
192+
`:SRGB`.
193+
194+
### Returns
195+
196+
* A boolean.
197+
198+
### Examples
199+
200+
iex> palette = Color.Palette.tonal("#3b82f6")
201+
iex> Color.Palette.in_gamut?(palette)
202+
true
203+
204+
iex> theme = Color.Palette.theme("#3b82f6")
205+
iex> Color.Palette.in_gamut?(theme, :SRGB)
206+
true
207+
208+
"""
209+
@spec in_gamut?(struct(), Color.Types.working_space()) :: boolean()
210+
def in_gamut?(palette, working_space \\ :SRGB)
211+
def in_gamut?(%Tonal{} = p, ws), do: Tonal.in_gamut?(p, ws)
212+
def in_gamut?(%Theme{} = p, ws), do: Theme.in_gamut?(p, ws)
213+
def in_gamut?(%Contrast{} = p, ws), do: Contrast.in_gamut?(p, ws)
214+
def in_gamut?(%ContrastScale{} = p, ws), do: ContrastScale.in_gamut?(p, ws)
215+
216+
@doc """
217+
Returns a detailed gamut report on the given palette.
218+
219+
Dispatches on palette type — see each palette module's
220+
`gamut_report/2` for the returned map's exact shape.
221+
222+
### Arguments
223+
224+
* `palette` is any palette struct produced by this module.
225+
226+
* `working_space` defaults to `:SRGB`.
227+
228+
### Returns
229+
230+
* A map. The top-level `:in_gamut?` key is present on every
231+
palette type.
232+
233+
### Examples
234+
235+
iex> palette = Color.Palette.tonal("#3b82f6")
236+
iex> report = Color.Palette.gamut_report(palette, :SRGB)
237+
iex> report.in_gamut?
238+
true
239+
240+
"""
241+
@spec gamut_report(struct(), Color.Types.working_space()) :: map()
242+
def gamut_report(palette, working_space \\ :SRGB)
243+
def gamut_report(%Tonal{} = p, ws), do: Tonal.gamut_report(p, ws)
244+
def gamut_report(%Theme{} = p, ws), do: Theme.gamut_report(p, ws)
245+
def gamut_report(%Contrast{} = p, ws), do: Contrast.gamut_report(p, ws)
246+
def gamut_report(%ContrastScale{} = p, ws), do: ContrastScale.gamut_report(p, ws)
175247
end

lib/color/palette/contrast.ex

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,86 @@ defmodule Color.Palette.Contrast do
183183
end
184184
end
185185

186+
@doc """
187+
Returns `true` when every reachable stop in the palette is
188+
inside the given RGB working space.
189+
190+
Unreachable stops (`:unreachable` sentinel) are ignored — they
191+
have no colour to check.
192+
193+
### Arguments
194+
195+
* `palette` is a `Color.Palette.Contrast` struct.
196+
197+
* `working_space` is an RGB working-space atom. Defaults to
198+
`:SRGB`.
199+
200+
### Returns
201+
202+
* A boolean.
203+
204+
### Examples
205+
206+
iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
207+
iex> Color.Palette.Contrast.in_gamut?(palette, :SRGB)
208+
true
209+
210+
"""
211+
@spec in_gamut?(t(), Color.Types.working_space()) :: boolean()
212+
def in_gamut?(%__MODULE__{stops: stops}, working_space \\ :SRGB) do
213+
Enum.all?(stops, fn
214+
%{color: :unreachable} -> true
215+
%{color: color} -> Color.Gamut.in_gamut?(color, working_space)
216+
end)
217+
end
218+
219+
@doc """
220+
Returns a detailed gamut report on a contrast palette.
221+
222+
Each reachable stop becomes a `%{target, color, in_gamut?}`
223+
entry; unreachable stops become `%{target, color: :unreachable}`.
224+
225+
### Arguments
226+
227+
* `palette` is a `Color.Palette.Contrast` struct.
228+
229+
* `working_space` is an RGB working-space atom. Defaults to
230+
`:SRGB`.
231+
232+
### Returns
233+
234+
* A map with `:working_space`, `:in_gamut?`, `:stops`, and
235+
`:out_of_gamut`.
236+
237+
### Examples
238+
239+
iex> palette = Color.Palette.Contrast.new("#3b82f6", targets: [4.5, 7.0])
240+
iex> %{in_gamut?: true} = Color.Palette.Contrast.gamut_report(palette, :SRGB)
241+
242+
"""
243+
@spec gamut_report(t(), Color.Types.working_space()) :: map()
244+
def gamut_report(%__MODULE__{stops: stops}, working_space \\ :SRGB) do
245+
entries =
246+
Enum.map(stops, fn
247+
%{color: :unreachable} = stop ->
248+
%{target: stop.target, color: :unreachable, in_gamut?: true}
249+
250+
%{color: color, target: target} ->
251+
%{
252+
target: target,
253+
color: color,
254+
in_gamut?: Color.Gamut.in_gamut?(color, working_space)
255+
}
256+
end)
257+
258+
%{
259+
working_space: working_space,
260+
in_gamut?: Enum.all?(entries, & &1.in_gamut?),
261+
stops: entries,
262+
out_of_gamut: Enum.reject(entries, & &1.in_gamut?)
263+
}
264+
end
265+
186266
@doc """
187267
Emits the palette as a W3C [Design Tokens Community Group](https://www.designtokens.org/)
188268
color-token group.

lib/color/palette/contrast_scale.ex

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,54 @@ defmodule Color.Palette.ContrastScale do
319319
Map.put(token, "$extensions", ext)
320320
end
321321

322+
@doc """
323+
Returns `true` when every stop in the palette is inside the
324+
given RGB working space. See `Color.Palette.Tonal.in_gamut?/2`
325+
for details.
326+
327+
### Examples
328+
329+
iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
330+
iex> Color.Palette.ContrastScale.in_gamut?(palette, :SRGB)
331+
true
332+
333+
"""
334+
@spec in_gamut?(t(), Color.Types.working_space()) :: boolean()
335+
def in_gamut?(%__MODULE__{stops: stops}, working_space \\ :SRGB) do
336+
Enum.all?(stops, fn {_label, color} ->
337+
Color.Gamut.in_gamut?(color, working_space)
338+
end)
339+
end
340+
341+
@doc """
342+
Returns a detailed per-stop gamut report. See
343+
`Color.Palette.Tonal.gamut_report/2` for the shape.
344+
345+
### Examples
346+
347+
iex> palette = Color.Palette.ContrastScale.new("#3b82f6")
348+
iex> %{in_gamut?: true} = Color.Palette.ContrastScale.gamut_report(palette, :SRGB)
349+
350+
"""
351+
@spec gamut_report(t(), Color.Types.working_space()) :: map()
352+
def gamut_report(%__MODULE__{} = palette, working_space \\ :SRGB) do
353+
stops =
354+
palette
355+
|> labels()
356+
|> Enum.map(fn label ->
357+
color = Map.fetch!(palette.stops, label)
358+
in_gamut? = Color.Gamut.in_gamut?(color, working_space)
359+
%{label: label, color: color, in_gamut?: in_gamut?}
360+
end)
361+
362+
%{
363+
working_space: working_space,
364+
in_gamut?: Enum.all?(stops, & &1.in_gamut?),
365+
stops: stops,
366+
out_of_gamut: Enum.reject(stops, & &1.in_gamut?)
367+
}
368+
end
369+
322370
@doc """
323371
Emits the palette as a block of **CSS custom properties**. See
324372
`Color.Palette.Tonal.to_css/2` for option details.

0 commit comments

Comments
 (0)