Skip to content

Commit dac1448

Browse files
committed
Refactor to make all palette/SVG/CSS/DCGT data programatically available
1 parent 357fd7f commit dac1448

15 files changed

Lines changed: 1109 additions & 543 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ All notable changes to this project are documented here. The format is based on
66

77
### Added
88

9+
* 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.
10+
911
* `Color.Gamut.Diagram` — pure-data module for chromaticity diagrams.
1012

11-
* `Color.Palette.Visualizer` gains a `/gamut` tab rendering an inline SVG chromaticity diagram. Supports both CIE 1976 u′v′ (default) and CIE 1931 xy projections, toggleable overlays for sRGB / Display P3 / Rec. 2020 / Adobe RGB / ProPhoto RGB, an optional Planckian locus with annotated CCT points, an optional seed-chromaticity dot, and an optional tonal-palette overlay that plots every stop of the seed's tonal scale as a chain of coloured dots with hoverable labels so users can see that their whole palette sits inside a target gamut. A raw SVG export block at the bottom makes it easy to paste the diagram into a design doc.
13+
* `Color.Palette.Visualizer` gains a `/gamut` tab rendering an inline SVG chromaticity diagram.
1214

1315
* `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/).
1416

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Color.Contrast.apca("black", "white") # 106.04
125125

126126
### Palettes
127127

128-
See the [palette guide](https://hexdocs.pm/color/palettes.html) for background, research, and when to use each algorithm, and the [visualizer guide](https://hexdocs.pm/color/visualizer.html) for the optional Plug-based web preview (`Color.Palette.Visualizer`).
128+
See the [palette guide](https://hexdocs.pm/color/palettes.html) for background, research, and when to use each algorithm, the [visualizer guide](https://hexdocs.pm/color/visualizer.html) for the optional Plug-based web preview (`Color.Palette.Visualizer`), and the [integrations guide](https://hexdocs.pm/color/integrations.html) for building design-system tooling on top of `Color.Palette.Tonal.to_css/2` / `to_tailwind/2` / `to_tokens/2` and `Color.Gamut.SVG.render/1`.
129129

130130
Generate design-system palettes from a single seed colour:
131131

guides/images/gamut-xy.png

27 KB
Loading

guides/images/gamut.png

28.5 KB
Loading

guides/integrations.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# Integrations Guide — Building Designer Tools on `Color`
2+
3+
This guide is for developers building design-adjacent tooling — Figma plugins, Style Dictionary transforms, Mix tasks that regenerate CSS token files from a brand colour, Phoenix LiveView dashboards for a design system, static-site generators that embed gamut diagrams, CI jobs that audit palette accessibility, and so on.
4+
5+
Everything the visualizer does is reachable programmatically. The visualizer is a *consumer* of the library's public API. You can generate a palette, export it in any supported format, and render a gamut diagram as SVG — all without going near a Plug router.
6+
7+
## The pieces
8+
9+
| Task | API |
10+
|---|---|
11+
| Generate a palette | `Color.Palette.tonal/2`, `theme/2`, `contrast/2`, `contrast_scale/2` |
12+
| Export as CSS custom properties | `Color.Palette.Tonal.to_css/2`, `Color.Palette.ContrastScale.to_css/2` |
13+
| Export as Tailwind config | `Color.Palette.Tonal.to_tailwind/2`, `Color.Palette.ContrastScale.to_tailwind/2` |
14+
| 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` |
15+
| Encode JSON | `:json.encode/1` (Erlang built-in) |
16+
| Render a gamut diagram as SVG | `Color.Gamut.SVG.render/1` |
17+
| Get raw gamut geometry | `Color.Gamut.Diagram.spectral_locus/2`, `triangle/2`, `planckian_locus/2`, `chromaticity/2` |
18+
| Encode / decode individual colours as DTCG | `Color.DesignTokens.encode/2`, `decode/1` |
19+
20+
## Generating palettes
21+
22+
All four algorithms take the same shape — a seed colour and an options keyword list — and return a struct.
23+
24+
### Tonal scale (Tailwind-style)
25+
26+
```elixir
27+
palette = Color.Palette.tonal("#3b82f6", name: "blue")
28+
29+
# %Color.Palette.Tonal{
30+
# name: "blue",
31+
# seed: %Color.SRGB{...},
32+
# seed_stop: 500,
33+
# stops: %{50 => ..., 100 => ..., ..., 950 => ...},
34+
# options: [...]
35+
# }
36+
```
37+
38+
See [the palette guide](palettes.md) for the algorithm's details and when each algorithm is the right choice.
39+
40+
### Theme (Material Design 3 style)
41+
42+
```elixir
43+
theme = Color.Palette.theme("#3b82f6")
44+
45+
{:ok, primary} = Color.Palette.Theme.role(theme, :primary)
46+
{:ok, on_surface_dark} = Color.Palette.Theme.role(theme, :on_surface, scheme: :dark)
47+
```
48+
49+
### Contrast (Leonardo style)
50+
51+
```elixir
52+
palette = Color.Palette.contrast("#3b82f6",
53+
background: "white",
54+
targets: [3.0, 4.5, 7.0, 10.0]
55+
)
56+
57+
# Each stop records the target and the achieved contrast, and
58+
# marks unreachable targets rather than silently falling back.
59+
```
60+
61+
### Contrast-constrained tonal scale
62+
63+
```elixir
64+
scale = Color.Palette.contrast_scale("#3b82f6",
65+
guarantee: {4.5, 500}
66+
)
67+
68+
# Any two stops whose labels differ by 500 or more are guaranteed
69+
# to contrast at least 4.5:1 against each other.
70+
```
71+
72+
## Exporting to CSS custom properties
73+
74+
```elixir
75+
palette = Color.Palette.tonal("#3b82f6", name: "blue")
76+
css = Color.Palette.Tonal.to_css(palette)
77+
#=>
78+
# :root {
79+
# --blue-50: #f4f9ff;
80+
# --blue-100: #c9deff;
81+
# ...
82+
# --blue-950: #000825;
83+
# }
84+
85+
File.write!("priv/static/brand-tokens.css", css)
86+
```
87+
88+
Both the declaration selector and the property name prefix are overridable:
89+
90+
```elixir
91+
Color.Palette.Tonal.to_css(palette,
92+
selector: "[data-theme='light']",
93+
name: "brand"
94+
)
95+
#=> [data-theme='light'] {
96+
# --brand-50: #f4f9ff;
97+
# ...
98+
```
99+
100+
`Color.Palette.ContrastScale.to_css/2` has the same signature.
101+
102+
## Exporting to Tailwind CSS
103+
104+
```elixir
105+
palette = Color.Palette.tonal("#ec4899", name: "pink")
106+
tailwind = Color.Palette.Tonal.to_tailwind(palette)
107+
#=>
108+
# theme: {
109+
# extend: {
110+
# colors: {
111+
# pink: {
112+
# 50: "#fff1f5",
113+
# 100: "#ffd0de",
114+
# ...
115+
# }
116+
# }
117+
# }
118+
# }
119+
```
120+
121+
Drop it into `tailwind.config.js` (or a generated wrapper) at build time:
122+
123+
```elixir
124+
# Mix task: regenerate Tailwind theme from a brand colour
125+
defmodule Mix.Tasks.Brand.Tailwind do
126+
use Mix.Task
127+
128+
def run([hex]) do
129+
palette = Color.Palette.tonal(hex, name: "brand")
130+
config = Color.Palette.Tonal.to_tailwind(palette)
131+
132+
"""
133+
module.exports = {
134+
content: ["./lib/**/*.{ex,heex}"],
135+
#{config}
136+
}
137+
"""
138+
|> then(&File.write!("assets/tailwind.config.js", &1))
139+
140+
Mix.shell().info("Regenerated Tailwind config from #{hex}")
141+
end
142+
end
143+
```
144+
145+
## Exporting to W3C Design Tokens (DTCG)
146+
147+
All four palette types emit DTCG 2025.10 colour tokens.
148+
149+
```elixir
150+
palette = Color.Palette.tonal("#3b82f6", name: "blue")
151+
tokens = Color.Palette.Tonal.to_tokens(palette)
152+
#=> %{
153+
# "blue" => %{
154+
# "50" => %{"$type" => "color", "$value" => %{...}},
155+
# "500" => %{"$type" => "color", "$value" => %{"colorSpace" => "oklch", "components" => [...], "hex" => "#3b82f6"}},
156+
# ...
157+
# }
158+
# }
159+
160+
File.write!("tokens/blue.json", :json.encode(tokens) |> IO.iodata_to_binary())
161+
```
162+
163+
The default encoded space is Oklch — richer than sRGB and the modern standard — with a `"hex"` fallback for tools that don't yet speak Oklch. Override via `:space`:
164+
165+
```elixir
166+
# Emit as sRGB for a stricter downstream tool
167+
Color.Palette.Tonal.to_tokens(palette, space: Color.SRGB)
168+
```
169+
170+
For **themes**, the export emits both the five tonal sub-palettes and a `role` group with Material 3 role tokens as **DTCG alias tokens** pointing at the underlying stops — so tools that resolve aliases get both layers:
171+
172+
```elixir
173+
theme = Color.Palette.theme("#3b82f6")
174+
tokens = Color.Palette.Theme.to_tokens(theme, scheme: :light)
175+
176+
tokens["palette"]["primary"]["40"]
177+
#=> %{"$type" => "color", "$value" => %{"colorSpace" => "oklch", ...}}
178+
179+
tokens["role"]["primary"]
180+
#=> %{"$type" => "color", "$value" => "{palette.primary.40}"}
181+
```
182+
183+
## Rendering gamut diagrams as SVG
184+
185+
`Color.Gamut.SVG.render/1` returns a complete, styleable SVG string — no Plug, no browser, no Chrome necessary. Drop it into HTML, paste it into a blog post, attach it to a design review.
186+
187+
### Basic diagram
188+
189+
```elixir
190+
svg = Color.Gamut.SVG.render(projection: :uv, gamuts: [:SRGB, :P3_D65])
191+
File.write!("gamut.svg", svg)
192+
```
193+
194+
### With a palette overlay
195+
196+
```elixir
197+
palette = Color.Palette.tonal("#3b82f6")
198+
199+
svg =
200+
Color.Gamut.SVG.render(
201+
projection: :uv,
202+
gamuts: [:SRGB, :P3_D65, :Rec2020],
203+
palette: palette,
204+
seed: "#3b82f6",
205+
planckian: true
206+
)
207+
```
208+
209+
The palette's stops are plotted as a chain of coloured dots — each dot has a `<title>` so browsers show its label and hex on hover. Use this to verify visually that every stop in a generated palette lives inside your target display space.
210+
211+
### Sizing and colour overrides
212+
213+
```elixir
214+
Color.Gamut.SVG.render(
215+
width: 400,
216+
height: 300,
217+
gamut_colours: %{SRGB: "#000", P3_D65: "#888"}
218+
)
219+
```
220+
221+
### Accessing raw geometry
222+
223+
If you need the points directly — to render with a different library, embed in a PDF with `ChunkyPDF`, emit as PostScript, whatever — `Color.Gamut.Diagram` returns them as plain data:
224+
225+
```elixir
226+
locus = Color.Gamut.Diagram.spectral_locus(:uv, step: 10)
227+
#=> [%{wavelength: 380.0, u: 0.2568, v: 0.0165}, ...]
228+
229+
sRGB_triangle = Color.Gamut.Diagram.triangle(:SRGB, :uv)
230+
#=> %{red: %{u: ..., v: ...}, green: ..., blue: ..., white: ...}
231+
232+
{:ok, blue_point} = Color.Gamut.Diagram.chromaticity("#3b82f6", :uv)
233+
```
234+
235+
## Decoding external Design Tokens
236+
237+
If you're importing a DTCG token file from Figma, Style Dictionary, or another tool, decode individual colour tokens back into `Color.*` structs:
238+
239+
```elixir
240+
raw = File.read!("incoming-tokens.json") |> :json.decode()
241+
token = raw["color"]["primary"]
242+
243+
case Color.DesignTokens.decode(token) do
244+
{:ok, %Color.Oklch{} = oklch} ->
245+
# use it
246+
Color.to_hex(oklch)
247+
248+
{:error, %Color.DesignTokensDecodeError{reason: :alias_not_resolved}} ->
249+
# the caller has the full token tree, resolve and retry
250+
:needs_resolution
251+
252+
{:error, e} ->
253+
IO.warn("unsupported: #{Exception.message(e)}")
254+
end
255+
```
256+
257+
The decoder rejects DTCG alias tokens (`{path.to.token}`) because resolving them requires the full token tree. Resolve aliases in the caller, then hand the final `$value` map here.
258+
259+
## A complete worked example
260+
261+
A Mix task that takes a brand hex, writes CSS, Tailwind, DTCG JSON, and a gamut SVG:
262+
263+
```elixir
264+
defmodule Mix.Tasks.Brand.Generate do
265+
use Mix.Task
266+
267+
@shortdoc "Regenerates brand palette artifacts from a single seed"
268+
269+
def run([hex]) do
270+
palette = Color.Palette.tonal(hex, name: "brand")
271+
File.mkdir_p!("priv/brand")
272+
273+
# 1. CSS custom properties
274+
File.write!(
275+
"priv/brand/brand.css",
276+
Color.Palette.Tonal.to_css(palette)
277+
)
278+
279+
# 2. Tailwind config fragment
280+
File.write!(
281+
"priv/brand/brand.tailwind.js",
282+
Color.Palette.Tonal.to_tailwind(palette)
283+
)
284+
285+
# 3. DTCG tokens
286+
tokens = Color.Palette.Tonal.to_tokens(palette)
287+
288+
File.write!(
289+
"priv/brand/brand.tokens.json",
290+
:json.encode(tokens) |> IO.iodata_to_binary()
291+
)
292+
293+
# 4. Gamut diagram showing palette coverage across common spaces
294+
svg =
295+
Color.Gamut.SVG.render(
296+
projection: :uv,
297+
gamuts: [:SRGB, :P3_D65, :Rec2020],
298+
palette: palette,
299+
seed: hex,
300+
planckian: true
301+
)
302+
303+
File.write!("priv/brand/brand.gamut.svg", svg)
304+
305+
Mix.shell().info("Wrote brand artifacts for #{hex}")
306+
end
307+
end
308+
```
309+
310+
Run it with `mix brand.generate "#3b82f6"` and you have four coordinated artefacts ready to ship.
311+
312+
## When to reach for what
313+
314+
| You're building… | Reach for |
315+
|---|---|
316+
| A design-token pipeline that feeds Figma / Style Dictionary | `to_tokens/2` + `:json` |
317+
| A live theme editor for a customer-branded SaaS | `to_css/2` + LiveView |
318+
| A Tailwind-only site with generated brand colours | `to_tailwind/2` + a Mix task |
319+
| 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 |
321+
| A custom diagram renderer (PDF, PostScript, Canvas) | `Color.Gamut.Diagram.spectral_locus/2` + `triangle/2` |
322+
| Ingesting a DTCG file from Figma | `Color.DesignTokens.decode/1` |
323+
324+
## Related
325+
326+
* [Palette guide](palettes.md) — background on the four palette algorithms.
327+
* [Visualizer guide](visualizer.md) — the web UI this guide's APIs back.
328+
* [`Color.Palette`](https://hexdocs.pm/color/Color.Palette.html) — palette API.
329+
* [`Color.DesignTokens`](https://hexdocs.pm/color/Color.DesignTokens.html) — individual-colour DTCG codec.
330+
* [`Color.Gamut.SVG`](https://hexdocs.pm/color/Color.Gamut.SVG.html) — SVG renderer.
331+
* [`Color.Gamut.Diagram`](https://hexdocs.pm/color/Color.Gamut.Diagram.html) — raw geometric data.

0 commit comments

Comments
 (0)