Skip to content

Commit b2b7b50

Browse files
committed
Release 0.13.0
2 parents 0e8d140 + ed081fb commit b2b7b50

3 files changed

Lines changed: 165 additions & 160 deletions

File tree

CHANGELOG.md

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

1717
* `Color.Palette.sort/2`'s default `:hue_origin` is now `15.0°` (was `0.0°`), placing the hue-circle cut just below pure red so the deepest reds anchor at the start of the strip and magentas/pinks (Oklch H < 15°) wrap past purple to the end. Pass `hue_origin: 0.0` to restore the previous cut-at-zero behaviour. The default applies to both `:hue_lightness` and `:stepped_hue` strategies.
1818

19+
## [0.12.1] — April 25th, 2026
20+
21+
### Bug Fixes
22+
23+
* Conditionally compile Visualizer and Standalone on optional deps.
24+
1925
## [0.12.0] — April 23rd, 2026
2026

2127
### Adds
@@ -74,14 +80,14 @@ All notable changes to this project are documented here. The format is based on
7480

7581
* 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.
7682

77-
* Public APIs for designer-tool integrations.
78-
* `Color.Palette.Tonal.to_css/2` and `Color.Palette.ContrastScale.to_css/2` emit CSS custom-property blocks (selector and name prefix configurable).
79-
* `Color.Palette.Tonal.to_tailwind/2` and `Color.Palette.ContrastScale.to_tailwind/2` emit `theme.extend.colors` fragments ready for `tailwind.config.js`.
80-
* `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.
83+
* Public APIs for designer-tool integrations.
84+
* `Color.Palette.Tonal.to_css/2` and `Color.Palette.ContrastScale.to_css/2` emit CSS custom-property blocks (selector and name prefix configurable).
85+
* `Color.Palette.Tonal.to_tailwind/2` and `Color.Palette.ContrastScale.to_tailwind/2` emit `theme.extend.colors` fragments ready for `tailwind.config.js`.
86+
* `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.
8187

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

84-
* `Color.Palette.Visualizer` gains a `/gamut` tab rendering an inline SVG chromaticity diagram.
90+
* `Color.Palette.Visualizer` gains a `/gamut` tab rendering an inline SVG chromaticity diagram.
8591

8692
* `Color.Palette.ContrastScale` — a fourth palette algorithm, inspired by Matt Ström-Awn's [Generating colour palettes with math](https://mattstromawn.com/writing/generating-color-palettes/).
8793

@@ -97,7 +103,7 @@ All notable changes to this project are documented here. The format is based on
97103

98104
### Added
99105

100-
* `Color.Palette` module with three palette-generation algorithms for design systems and web sites.
106+
* `Color.Palette` module with three palette-generation algorithms for design systems and web sites.
101107

102108
* `Color.Palette.Visualizer` — a Plug-based web UI for previewing the three palette algorithms. Three views modelled on [UI Colors](https://uicolors.app/generate) (Tonal), [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/) (Theme), and [Adobe Leonardo](https://leonardocolor.io/) (Contrast).
103109

lib/color/palette/visualizer.ex

Lines changed: 57 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,81 @@
1-
defmodule Color.Palette.Visualizer do
2-
@moduledoc """
3-
A web-based visualizer for the palettes produced by
4-
`Color.Palette`.
1+
# `Color.Palette.Visualizer` is a `Plug.Router`. It is only
2+
# defined when both `:plug` and `:plug_router` are available
3+
# (i.e., when the consumer has added `{:plug, "~> 1.15"}` to
4+
# their deps). When `:plug` isn't installed, the module simply
5+
# doesn't exist — calling any function on it raises the
6+
# standard `UndefinedFunctionError: module not available`,
7+
# which is the conventional Elixir signal for "you're missing
8+
# a dependency."
9+
if Code.ensure_loaded?(Plug.Router) do
10+
defmodule Color.Palette.Visualizer do
11+
@moduledoc """
12+
A web-based visualizer for the palettes produced by
13+
`Color.Palette`.
514
6-
This module is a `Plug.Router` that can be mounted inside a
7-
Phoenix or Plug application, or run standalone during
8-
development via `Color.Palette.Visualizer.Standalone`.
15+
This module is a `Plug.Router` that can be mounted inside a
16+
Phoenix or Plug application, or run standalone during
17+
development via `Color.Palette.Visualizer.Standalone`.
918
10-
## Views
19+
## Views
1120
12-
* `/tonal` — [UI Colors](https://uicolors.app/) style. One seed
13-
becomes a row of swatches with hex, OKLCH, and contrast
14-
values, plus exportable CSS custom properties and Tailwind
15-
config.
21+
* `/tonal` — [UI Colors](https://uicolors.app/) style. One seed
22+
becomes a row of swatches with hex, OKLCH, and contrast
23+
values, plus exportable CSS custom properties and Tailwind
24+
config.
1625
17-
* `/theme` — [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/)
18-
style. Five tonal scales and a grid of Material Design 3
19-
role tokens (primary / on-primary / surface / outline / …)
20-
for light and dark schemes.
26+
* `/theme` — [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/)
27+
style. Five tonal scales and a grid of Material Design 3
28+
role tokens (primary / on-primary / surface / outline / …)
29+
for light and dark schemes.
2130
22-
* `/contrast` — [Adobe Leonardo](https://leonardocolor.io/)
23-
style. Contrast-targeted swatches against a chosen background
24-
and a pass/fail matrix for common text sizes.
31+
* `/contrast` — [Adobe Leonardo](https://leonardocolor.io/)
32+
style. Contrast-targeted swatches against a chosen background
33+
and a pass/fail matrix for common text sizes.
2534
26-
* `/scale` — Matt Ström-Awn's contrast-constrained tonal scale.
35+
* `/scale` — Matt Ström-Awn's contrast-constrained tonal scale.
2736
28-
* `/gamut` — CIE chromaticity diagram with overlayable RGB
29-
working spaces and the optional planckian locus.
37+
* `/gamut` — CIE chromaticity diagram with overlayable RGB
38+
working spaces and the optional planckian locus.
3039
31-
* `/sort` — perceptually-ordered swatch strip from a free-form
32-
list of colours (rainbow, stepped-hue grid, lightness ramp,
33-
or material-aware PBR order).
40+
* `/sort` — perceptually-ordered swatch strip from a free-form
41+
list of colours (rainbow, stepped-hue grid, lightness ramp,
42+
or material-aware PBR order).
3443
35-
* `/spectrum` — diagnostic view: a hue-frequency strip of the
36-
input colour list, with achromatic entries shown separately
37-
by lightness. Useful for spotting hue gaps and
38-
achromatic/chromatic balance in any palette.
44+
* `/spectrum` — diagnostic view: a hue-frequency strip of the
45+
input colour list, with achromatic entries shown separately
46+
by lightness. Useful for spotting hue gaps and
47+
achromatic/chromatic balance in any palette.
3948
40-
All state lives in the URL — copy a URL and you've shared the
41-
palette.
49+
All state lives in the URL — copy a URL and you've shared the
50+
palette.
4251
43-
## Mounting in Phoenix
52+
## Mounting in Phoenix
4453
45-
In your `router.ex`:
54+
In your `router.ex`:
4655
47-
forward "/palette", Color.Palette.Visualizer
56+
forward "/palette", Color.Palette.Visualizer
4857
49-
## Running standalone
58+
## Running standalone
5059
51-
Color.Palette.Visualizer.Standalone.start(port: 4001)
60+
Color.Palette.Visualizer.Standalone.start(port: 4001)
5261
53-
## Optional dependencies
62+
## Optional dependencies
5463
55-
The visualizer pulls in `:plug` (required for the router) and
56-
`:bandit` (only used by the standalone helper). Both are
57-
declared `optional: true` in this library's `mix.exs`, so you
58-
must add them to your own project's deps to use the visualizer:
64+
The visualizer pulls in `:plug` (required for the router) and
65+
`:bandit` (only used by the standalone helper). Both are
66+
declared `optional: true` in this library's `mix.exs`, so you
67+
must add them to your own project's deps to use the visualizer:
5968
60-
{:plug, "~> 1.15"},
61-
{:bandit, "~> 1.5"}
69+
{:plug, "~> 1.15"},
70+
{:bandit, "~> 1.5"}
6271
63-
The core palette algorithms have no such dependency and will
64-
compile without either of these in place.
72+
The core palette algorithms have no such dependency and will
73+
compile without either of these in place. This module itself
74+
is only compiled when `:plug` is present — without it,
75+
`Color.Palette.Visualizer` doesn't exist as a module.
6576
66-
"""
77+
"""
6778

68-
# Deferred compile-time check: raise at use time, not at
69-
# compile time of this library, so users who never touch the
70-
# visualizer don't need plug installed.
71-
if Code.ensure_loaded?(Plug.Router) do
7279
use Plug.Router
7380

7481
plug(Plug.Logger, log: :debug)
@@ -371,14 +378,5 @@ defmodule Color.Palette.Visualizer do
371378
end
372379

373380
defp atom_default(_, _, default), do: default
374-
else
375-
@compile_error "Color.Palette.Visualizer requires :plug. " <>
376-
"Add `{:plug, \"~> 1.15\"}` to your project's deps."
377-
378-
@doc false
379-
def init(_), do: raise(@compile_error)
380-
381-
@doc false
382-
def call(_, _), do: raise(@compile_error)
383381
end
384382
end
Lines changed: 96 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,113 @@
1-
defmodule Color.Palette.Visualizer.Standalone do
2-
@moduledoc """
3-
A tiny helper that runs `Color.Palette.Visualizer` as a
4-
standalone web server for local development.
5-
6-
Requires `:bandit` in your project's deps.
7-
8-
Color.Palette.Visualizer.Standalone.start(port: 4001)
9-
# Visit http://localhost:4001
10-
11-
To stop the server, call
12-
`Color.Palette.Visualizer.Standalone.stop/1` with the PID
13-
returned from `start/1`.
14-
15-
"""
16-
17-
@doc """
18-
Starts the visualizer on the given port.
19-
20-
### Options
21-
22-
* `:port` — TCP port to listen on. Default `4001`.
23-
24-
* `:ip` — IP address to bind to. Default `:loopback` (only
25-
accessible from localhost). Pass `:any` to bind on all
26-
interfaces.
27-
28-
### Returns
29-
30-
* `{:ok, pid}` on success.
31-
32-
* `{:error, reason}` on failure — most commonly a port-in-use
33-
error.
34-
35-
"""
36-
@spec start(keyword()) :: {:ok, pid()} | {:error, term()}
37-
def start(options \\ []) do
38-
ensure_bandit!()
39-
40-
port = Keyword.get(options, :port, 4001)
41-
ip = Keyword.get(options, :ip, :loopback)
42-
43-
bandit_options = [
44-
plug: Color.Palette.Visualizer,
45-
port: port,
46-
ip: ip_tuple(ip)
47-
]
48-
49-
Bandit.start_link(bandit_options)
50-
end
51-
52-
@doc """
53-
Returns a child spec suitable for embedding under a
54-
supervision tree.
1+
# `Color.Palette.Visualizer.Standalone` runs the visualizer as
2+
# a self-contained Bandit web server. It is only defined when
3+
# `:bandit` is available (which transitively pulls in `:plug`),
4+
# so consumer apps that don't need a standalone dev server
5+
# don't get compiler warnings about missing modules. When
6+
# `:bandit` isn't installed, this module simply doesn't exist
7+
# and any call to it raises the standard
8+
# `UndefinedFunctionError`.
9+
if Code.ensure_loaded?(Bandit) do
10+
defmodule Color.Palette.Visualizer.Standalone do
11+
@moduledoc """
12+
A tiny helper that runs `Color.Palette.Visualizer` as a
13+
standalone web server for local development.
14+
15+
Requires `:bandit` in your project's deps. This module is
16+
only compiled when `:bandit` is present — without it,
17+
`Color.Palette.Visualizer.Standalone` doesn't exist as a
18+
module and calling its functions raises
19+
`UndefinedFunctionError`.
20+
21+
Color.Palette.Visualizer.Standalone.start(port: 4001)
22+
# Visit http://localhost:4001
23+
24+
To stop the server, call
25+
`Color.Palette.Visualizer.Standalone.stop/1` with the PID
26+
returned from `start/1`.
27+
28+
"""
29+
30+
@doc """
31+
Starts the visualizer on the given port.
32+
33+
### Options
34+
35+
* `:port` — TCP port to listen on. Default `4001`.
36+
37+
* `:ip` — IP address to bind to. Default `:loopback` (only
38+
accessible from localhost). Pass `:any` to bind on all
39+
interfaces.
40+
41+
### Returns
42+
43+
* `{:ok, pid}` on success.
44+
45+
* `{:error, reason}` on failure — most commonly a port-in-use
46+
error.
47+
48+
"""
49+
@spec start(keyword()) :: {:ok, pid()} | {:error, term()}
50+
def start(options \\ []) do
51+
port = Keyword.get(options, :port, 4001)
52+
ip = Keyword.get(options, :ip, :loopback)
53+
54+
bandit_options = [
55+
plug: Color.Palette.Visualizer,
56+
port: port,
57+
ip: ip_tuple(ip)
58+
]
59+
60+
Bandit.start_link(bandit_options)
61+
end
5562

56-
### Options
63+
@doc """
64+
Returns a child spec suitable for embedding under a
65+
supervision tree.
5766
58-
See `start/1`.
67+
### Options
5968
60-
### Returns
69+
See `start/1`.
6170
62-
* A child specification map.
71+
### Returns
6372
64-
"""
65-
@spec child_spec(keyword()) :: Supervisor.child_spec()
66-
def child_spec(options \\ []) do
67-
ensure_bandit!()
73+
* A child specification map.
6874
69-
port = Keyword.get(options, :port, 4001)
70-
ip = Keyword.get(options, :ip, :loopback)
75+
"""
76+
@spec child_spec(keyword()) :: Supervisor.child_spec()
77+
def child_spec(options \\ []) do
78+
port = Keyword.get(options, :port, 4001)
79+
ip = Keyword.get(options, :ip, :loopback)
7180

72-
%{
73-
id: __MODULE__,
74-
start:
75-
{Bandit, :start_link, [[plug: Color.Palette.Visualizer, port: port, ip: ip_tuple(ip)]]},
76-
type: :supervisor
77-
}
78-
end
81+
%{
82+
id: __MODULE__,
83+
start:
84+
{Bandit, :start_link, [[plug: Color.Palette.Visualizer, port: port, ip: ip_tuple(ip)]]},
85+
type: :supervisor
86+
}
87+
end
7988

80-
@doc """
81-
Stops a standalone server started by `start/1`.
89+
@doc """
90+
Stops a standalone server started by `start/1`.
8291
83-
### Arguments
92+
### Arguments
8493
85-
* `pid` — the process identifier returned by `start/1`.
94+
* `pid` — the process identifier returned by `start/1`.
8695
87-
### Returns
96+
### Returns
8897
89-
* `:ok`.
98+
* `:ok`.
9099
91-
"""
92-
@spec stop(pid()) :: :ok
93-
def stop(pid) when is_pid(pid) do
94-
_ = Supervisor.stop(pid)
95-
:ok
96-
end
100+
"""
101+
@spec stop(pid()) :: :ok
102+
def stop(pid) when is_pid(pid) do
103+
_ = Supervisor.stop(pid)
104+
:ok
105+
end
97106

98-
# ---- helpers ------------------------------------------------------------
107+
# ---- helpers ------------------------------------------------------------
99108

100-
defp ensure_bandit! do
101-
unless Code.ensure_loaded?(Bandit) do
102-
raise """
103-
Color.Palette.Visualizer.Standalone requires :bandit.
104-
Add `{:bandit, "~> 1.5"}` to your project's deps and run mix deps.get.
105-
"""
106-
end
109+
defp ip_tuple(:loopback), do: {127, 0, 0, 1}
110+
defp ip_tuple(:any), do: {0, 0, 0, 0}
111+
defp ip_tuple({_, _, _, _} = tuple), do: tuple
107112
end
108-
109-
defp ip_tuple(:loopback), do: {127, 0, 0, 1}
110-
defp ip_tuple(:any), do: {0, 0, 0, 0}
111-
defp ip_tuple({_, _, _, _} = tuple), do: tuple
112113
end

0 commit comments

Comments
 (0)