|
| 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