Macaw.Layers.Theme (macaw_layers v0.3.0)

Copy Markdown View Source

The colors, fonts and sizes a page draws with (spec/04 §2, §3).

A theme is a struct of named slots: "series_1" … "series_n" for the categorical colors; the furniture "axis", "grid", "text", "background" and "surface"; the five roles "good", "warning", "bad", "info" and "neutral"; and "font_family", "font_size", "label_size" and "title_size". Its colors are one color table (§2.2): the theme's own colors over the default theme's, its extensions' and the CSS named colors, with the furniture and the series as names too, so Macaw.Layers.Color.color(:red) is the theme's red. Everything reads a theme through resolve/3, in the mode its output needs:

  • :literal - the stored value, which a canvas needs: a color a literal %Macaw.Layers.Color{};
  • :css - a custom-property reference with the literal as its fallback, var(--mw-axis, #666666), so a stylesheet can override it from any ancestor.

Its colors are terms (§2.1): a literal is %Macaw.Layers.Color{}, read from text at the edge (Macaw.Layers.Color.parse!/1, ~RGB"#3b6fa8"), and a string is never a color.

Its names are strings (§2.1), the library's, its extensions' and a host's alike: its own name and its dark and light, the slots, the keys of ramps, colors and styles, and the name in every typed color ({:color, "good"}, {:ramp, "status", 0.5}). A name given as an atom is read as its string, once, when the theme is built (new/1, merge/2, a document read), so :info and "info" are one name; every function that takes a name takes either spelling. Its words — a style's keys and atom values, defaults — are kept as given.

Meaning: ramps, roles and cues

A series color promises distinct, never a meaning. A theme also carries four ramps whose meaning the library fixes (§2.3): "status", "thermal", "diverging" and "sequential", each a list of anchors interpolated in CIE Lab, and every role and ramp is held to its contract when a theme is built by new/1 or merge/2.

Whole styles: the style palette

styles carries the design over to whole styles (§2.4): style series, the roles as whole looks, view states, furniture styles and modifiers, the names the library's and its registered extensions' (§3.4). style/2 reads one, the default theme's where the theme lacks it.

Examples

iex> Macaw.Layers.Theme.resolve(Macaw.Layers.Theme.default(), :axis, :css)
"var(--mw-axis, #666666)"
iex> Macaw.Layers.Theme.resolve(Macaw.Layers.Theme.dark(), {:series, 11}, :literal)
%Macaw.Layers.Color{r: 127.0, g: 179.0, b: 230.0, opacity: 1.0}
iex> Macaw.Layers.Theme.resolve(Macaw.Layers.Theme.default(), :warning, :css)
"var(--mw-warning, #bd7407)"
iex> Macaw.Layers.Theme.resolve(Macaw.Layers.Theme.default(), {:ramp, :status, 0.5}, :css)
"var(--mw-warning, #bd7407)"

Ported from visualize's Visualize.Theme.

Summary

Types

How a slot renders: a CSS reference with its fallback, or the stored value.

One of the four ramps whose meaning the library fixes (§2.3): "status", "thermal", "diverging" or "sequential", an atom given read as its string.

A position along a ramp: t in 0..1, clamped.

A slot name, or {:series, i} for the i-th series color, cycling.

A name of the style palette, or {:style, i} for the i-th style series entry.

t()

Functions

The built-in theme of a name, "default", "dark", "color_safe" or "print", an atom read as its string, or nil.

:ok, or {:error, subject, message} for the first ramp, color or palette entry of the theme that breaks its contract, subject being {:ramp, name}, {:color, name} or {:style, name} and message naming the term broken with the values measured.

A color value held to a profile (§4): under :roles a value with a literal color among its leaves is {:error, {:literal_color, value}}; under :open every color passes. A term that is no color value, text included, raises ArgumentError.

Whether name is of the theme's color table, answered without building the list. A name is its string, an atom read as its string.

Every name of the theme's color table, sorted: its color slots, its own and the default theme's colors, its extensions' and the CSS named colors.

The color-vision-safe theme: default/0 with its status ramp blue → orange → red, which survives the commonest color-vision deficiencies, in place of green → amber → red.

The color slots: the series, then the furniture, then the five roles.

The custom-property name of a slot or a color name: --mw- and the name with _ as -. A name is lower-case letters, digits and _, starting with a letter; any other raises ArgumentError, so a stylesheet can never be broken out of through a name.

A role's cue besides its hue: its glyph and its dash (nil for solid), so a status reads without its hue. The role by its name, an atom read as its string. Raises ArgumentError for anything but a role.

The dark theme: light text and axes on #1b1e24.

A data color under the contrast guard (§4): {:ok, literal}, its literal color, where it reaches 3:1 against the theme's background, {:error, {:contrast, ratio}} where it does not, and {:error, :unreadable} for a term that is no color value or does not resolve, text included.

The light theme: dark text and axes on white.

The contrast floors a theme offered to people holds against its own background: text at least 4.5:1, axis and every series color at least 3:1, grid at least 1.2:1, and a surface different from the grid. :ok, or {:error, faults}, each {slot, {:contrast, ratio, floor}}, the slot by its name, or {"surface", {:equal, "grid"}}.

The contrast ink for a text drawn on under: the literal color of the theme's text or background, whichever contrasts more (text on a tie), or black or white, whichever contrasts more, when neither reaches 4.5:1. under is a literal %Macaw.Layers.Color{}, or nil for the theme's background; anything else, text included, raises ArgumentError. A slot that holds no literal color, in a theme built by hand, is skipped.

The theme with the given fields replaced, ramps, colors, styles and defaults merged key by key over its own; the checks of new/1 apply.

default/0 with the given fields replaced; ramps, colors, styles and defaults merged key by key. An unknown field raises KeyError; an empty series, a series or furniture color that is not a literal color, or a ramp, color or palette entry that breaks its contract, raises ArgumentError (§2.6). A string is never a color: read text with Macaw.Layers.Color.parse!/1, or write it in the source as ~RGB"#555555".

The print theme: paper. A white background and surface, near-black text and axes, a grid dark enough to survive a photocopy, the color-safe status ramp, and role styles whose fills are hatches of the role's color, so a grayscale copy keeps each role apart.

The color at t along the theme's ramp name, as a literal color: the anchors spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor the anchor's literal, t clamped. A theme that lacks the ramp takes the default theme's, never a series color.

The four ramps whose meaning the library fixes, by name.

The slot's value: the stored literal, or a CSS custom-property reference that falls back to its CSS text. A color is a literal %Macaw.Layers.Color{} under :literal; sizes are numbers under :literal and gain px under :css. A ramp position is its literal in both modes, as CSS text under :css, except where a status role stands, {:ramp, "status", 0 | 0.5 | 1}, which resolves as the role while the table holds the role there. A name, of a slot, a color or a ramp, is its string, an atom read as its string. An unknown name, or a series slot beyond the palette, raises ArgumentError.

The five roles: the status ramp's three fixed points, then "info" and "neutral".

The "series_k" slot the positive index i cycles to over the theme's series.

The theme's slot names: "series_1" to "series_n", then the fixed slots.

The palette entry name as one style node in its parts: the theme's own, the default theme's entry whole where the theme lacks it, or an extension's default. {:style, i} cycles over the style series; in a role's entry :cue becomes the role's dash and glyph, a solid dash leaving the key out. Any other name raises ArgumentError.

The theme's palette names: its style series, the default's ten when it has none of its own, then the roles, the states, the furniture and the modifiers, then its extensions'.

Every ramp, color and palette entry of the theme that breaks its contract, as {subject, message} in check/1's order, each contract run alone.

Types

mode()

@type mode() :: :css | :literal

How a slot renders: a CSS reference with its fallback, or the stored value.

ramp_name()

@type ramp_name() :: String.t() | atom()

One of the four ramps whose meaning the library fixes (§2.3): "status", "thermal", "diverging" or "sequential", an atom given read as its string.

ramp_position()

@type ramp_position() :: {:ramp, ramp_name(), number()}

A position along a ramp: t in 0..1, clamped.

slot()

@type slot() :: String.t() | atom() | {:series, pos_integer()}

A slot name, or {:series, i} for the i-th series color, cycling.

style_name()

@type style_name() :: String.t() | atom() | {:style, pos_integer()}

A name of the style palette, or {:style, i} for the i-th style series entry.

t()

@type t() :: %Macaw.Layers.Theme{
  axis: Macaw.Layers.Color.t(),
  background: Macaw.Layers.Color.t(),
  colors: %{optional(String.t()) => term()},
  dark: String.t() | nil,
  defaults: map(),
  extensions: [module()],
  font_family: String.t(),
  font_size: number(),
  grid: Macaw.Layers.Color.t(),
  label_size: number(),
  light: String.t() | nil,
  name: String.t(),
  ramps: %{optional(String.t()) => list()},
  series: [Macaw.Layers.Color.t(), ...],
  styles: %{optional(String.t()) => map() | list() | String.t()},
  surface: Macaw.Layers.Color.t(),
  text: Macaw.Layers.Color.t(),
  title_size: number()
}

Functions

builtin(name)

@spec builtin(atom() | String.t()) :: t() | nil

The built-in theme of a name, "default", "dark", "color_safe" or "print", an atom read as its string, or nil.

iex> Macaw.Layers.Theme.builtin(:color_safe).name
"color_safe"
iex> Macaw.Layers.Theme.builtin(:house)
nil

check(theme)

@spec check(t()) :: :ok | {:error, {:ramp | :color | :style, String.t()}, String.t()}

:ok, or {:error, subject, message} for the first ramp, color or palette entry of the theme that breaks its contract, subject being {:ramp, name}, {:color, name} or {:style, name} and message naming the term broken with the values measured.

iex> Macaw.Layers.Theme.check(Macaw.Layers.Theme.dark())
:ok
iex> status = Enum.map(~w(#c23238 #bd7407 #4ca34b), &Macaw.Layers.Color.parse!/1)
iex> theme = %{Macaw.Layers.Theme.default() | ramps: %{"status" => status}}
iex> {:error, {:ramp, "status"}, message} = Macaw.Layers.Theme.check(theme)
iex> message =~ "good (0) must be green or blue"
true

check_color(value, profile)

@spec check_color(term(), :roles | :open) :: :ok | {:error, {:literal_color, term()}}

A color value held to a profile (§4): under :roles a value with a literal color among its leaves is {:error, {:literal_color, value}}; under :open every color passes. A term that is no color value, text included, raises ArgumentError.

color_name?(theme, name)

@spec color_name?(t(), term()) :: boolean()

Whether name is of the theme's color table, answered without building the list. A name is its string, an atom read as its string.

iex> theme = Macaw.Layers.Theme.default()
iex> Enum.map([:red, "bad", :axis, "series_3", :steelblue, :series_99, :thick], &Macaw.Layers.Theme.color_name?(theme, &1))
[true, true, true, true, true, false, false]

color_names(theme)

@spec color_names(t()) :: [String.t(), ...]

Every name of the theme's color table, sorted: its color slots, its own and the default theme's colors, its extensions' and the CSS named colors.

iex> names = Macaw.Layers.Theme.color_names(Macaw.Layers.Theme.default())
iex> Enum.all?(~w(red amber bad axis series_3 steelblue), &(&1 in names))
true

color_safe()

@spec color_safe() :: t()

The color-vision-safe theme: default/0 with its status ramp blue → orange → red, which survives the commonest color-vision deficiencies, in place of green → amber → red.

color_slots(theme)

@spec color_slots(t()) :: [String.t(), ...]

The color slots: the series, then the furniture, then the five roles.

css_name(name)

@spec css_name(atom() | String.t()) :: String.t()

The custom-property name of a slot or a color name: --mw- and the name with _ as -. A name is lower-case letters, digits and _, starting with a letter; any other raises ArgumentError, so a stylesheet can never be broken out of through a name.

iex> Macaw.Layers.Theme.css_name(:series_1)
"--mw-series-1"

cue(role)

@spec cue(String.t() | atom()) :: %{glyph: atom(), dash: String.t() | nil}

A role's cue besides its hue: its glyph and its dash (nil for solid), so a status reads without its hue. The role by its name, an atom read as its string. Raises ArgumentError for anything but a role.

iex> Macaw.Layers.Theme.cue(:warning)
%{glyph: :triangle, dash: "6 3"}

dark()

@spec dark() :: t()

The dark theme: light text and axes on #1b1e24.

data_color(theme, value)

@spec data_color(t(), term()) ::
  {:ok, Macaw.Layers.Color.t()} | {:error, {:contrast, float()} | :unreadable}

A data color under the contrast guard (§4): {:ok, literal}, its literal color, where it reaches 3:1 against the theme's background, {:error, {:contrast, ratio}} where it does not, and {:error, :unreadable} for a term that is no color value or does not resolve, text included.

default()

@spec default() :: t()

The light theme: dark text and axes on white.

floors(theme)

@spec floors(t()) :: :ok | {:error, [{String.t(), term()}, ...]}

The contrast floors a theme offered to people holds against its own background: text at least 4.5:1, axis and every series color at least 3:1, grid at least 1.2:1, and a surface different from the grid. :ok, or {:error, faults}, each {slot, {:contrast, ratio, floor}}, the slot by its name, or {"surface", {:equal, "grid"}}.

ink(theme, under)

@spec ink(t(), Macaw.Layers.Color.t() | nil) :: Macaw.Layers.Color.t()

The contrast ink for a text drawn on under: the literal color of the theme's text or background, whichever contrasts more (text on a tie), or black or white, whichever contrasts more, when neither reaches 4.5:1. under is a literal %Macaw.Layers.Color{}, or nil for the theme's background; anything else, text included, raises ArgumentError. A slot that holds no literal color, in a theme built by hand, is skipped.

iex> import Macaw.Layers.Color, only: [sigil_RGB: 2]
iex> theme = Macaw.Layers.Theme.default()
iex> [Macaw.Layers.Theme.ink(theme, ~RGB"#3b6fa8"), Macaw.Layers.Theme.ink(theme, nil), Macaw.Layers.Theme.ink(theme, ~RGB"#777777")]
...> |> Enum.map(&Macaw.Layers.Color.to_css/1)
["#ffffff", "#333333", "#000000"]

merge(theme, fields)

@spec merge(t(), keyword() | map()) :: t()

The theme with the given fields replaced, ramps, colors, styles and defaults merged key by key over its own; the checks of new/1 apply.

iex> theme = Macaw.Layers.Theme.new(name: :house, styles: %{muted: %{opacity: 0.4}})
iex> Macaw.Layers.Theme.style(theme, :muted)
%{opacity: 0.4}
iex> Macaw.Layers.Theme.new(name: :house, colors: %{warning: Macaw.Layers.Color.parse!("#f5c242")})
** (ArgumentError) theme "house": style "warning" breaks its contract: fill {:color, "warning"} is 1.65:1 against #ffffff; a role style's paint must reach 3:1 (WCAG 1.4.11, non-text contrast)

Names are strings (§2.1): a name given as an atom is its string, so a host's "info" and the library's :info are one name, the one given winning; a name given both ways in one map raises.

iex> Macaw.Layers.Theme.new(colors: %{"info" => Macaw.Layers.Color.parse!("#2f5d8a")})
...> |> Macaw.Layers.Theme.resolve(:info, :css)
"var(--mw-info, #2f5d8a)"

new(fields)

@spec new(keyword() | map()) :: t()

default/0 with the given fields replaced; ramps, colors, styles and defaults merged key by key. An unknown field raises KeyError; an empty series, a series or furniture color that is not a literal color, or a ramp, color or palette entry that breaks its contract, raises ArgumentError (§2.6). A string is never a color: read text with Macaw.Layers.Color.parse!/1, or write it in the source as ~RGB"#555555".

iex> Macaw.Layers.Theme.new(axis: Macaw.Layers.Color.parse!("#555555")).axis
%Macaw.Layers.Color{r: 85.0, g: 85.0, b: 85.0, opacity: 1.0}
iex> Macaw.Layers.Theme.new(axis: "#555")
** (ArgumentError) theme "default": axis "#555" is text, and a string is never a color: read it with Macaw.Layers.Color.parse!/1, or write ~RGB"#555"

print()

@spec print() :: t()

The print theme: paper. A white background and surface, near-black text and axes, a grid dark enough to survive a photocopy, the color-safe status ramp, and role styles whose fills are hatches of the role's color, so a grayscale copy keeps each role apart.

ramp(theme, name, t)

@spec ramp(t(), ramp_name(), number()) :: Macaw.Layers.Color.t()

The color at t along the theme's ramp name, as a literal color: the anchors spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor the anchor's literal, t clamped. A theme that lacks the ramp takes the default theme's, never a series color.

iex> Macaw.Layers.Theme.ramp(Macaw.Layers.Theme.default(), :thermal, 0.25)
%Macaw.Layers.Color{r: 141.0, g: 165.0, b: 203.0, opacity: 1.0}

ramp_names()

@spec ramp_names() :: [String.t(), ...]

The four ramps whose meaning the library fixes, by name.

iex> Macaw.Layers.Theme.ramp_names()
["status", "thermal", "diverging", "sequential"]

resolve(theme, slot, mode)

@spec resolve(t(), slot() | ramp_position(), mode()) ::
  Macaw.Layers.Color.t() | String.t() | number()

The slot's value: the stored literal, or a CSS custom-property reference that falls back to its CSS text. A color is a literal %Macaw.Layers.Color{} under :literal; sizes are numbers under :literal and gain px under :css. A ramp position is its literal in both modes, as CSS text under :css, except where a status role stands, {:ramp, "status", 0 | 0.5 | 1}, which resolves as the role while the table holds the role there. A name, of a slot, a color or a ramp, is its string, an atom read as its string. An unknown name, or a series slot beyond the palette, raises ArgumentError.

role_names()

@spec role_names() :: [String.t(), ...]

The five roles: the status ramp's three fixed points, then "info" and "neutral".

series_slot(theme, i)

@spec series_slot(t(), pos_integer()) :: String.t()

The "series_k" slot the positive index i cycles to over the theme's series.

iex> Macaw.Layers.Theme.series_slot(Macaw.Layers.Theme.default(), 11)
"series_1"

slots(theme)

@spec slots(t()) :: [String.t()]

The theme's slot names: "series_1" to "series_n", then the fixed slots.

iex> Macaw.Layers.Theme.new(series: [Macaw.Layers.Color.parse!("#3b6fa8"), Macaw.Layers.Color.parse!("#d9730d")])
...> |> Macaw.Layers.Theme.slots()
...> |> Enum.take(4)
["series_1", "series_2", "axis", "grid"]

style(theme, name)

@spec style(t(), style_name()) :: map()

The palette entry name as one style node in its parts: the theme's own, the default theme's entry whole where the theme lacks it, or an extension's default. {:style, i} cycles over the style series; in a role's entry :cue becomes the role's dash and glyph, a solid dash leaving the key out. Any other name raises ArgumentError.

iex> Macaw.Layers.Theme.style(Macaw.Layers.Theme.default(), :warning)
%{fill: %{type: :solid, color: {:color, "warning"}}, stroke: %{type: :solid, color: {:color, "warning"}, dash: "6 3"}, marker: %{shape: :triangle}}
iex> Macaw.Layers.Theme.style(Macaw.Layers.Theme.default(), :muted)
%{opacity: 0.25}

style_names(theme)

@spec style_names(t()) :: [String.t(), ...]

The theme's palette names: its style series, the default's ten when it has none of its own, then the roles, the states, the furniture and the modifiers, then its extensions'.

violations(theme)

@spec violations(t()) :: [{{:ramp | :color | :style, String.t()}, String.t()}]

Every ramp, color and palette entry of the theme that breaks its contract, as {subject, message} in check/1's order, each contract run alone.