Macaw.Layers.Theme (macaw_layers v0.2.2)

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;
  • :css - a custom-property reference with the literal as its fallback, var(--mw-axis, #666666), so a stylesheet can override it from any ancestor.

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)
"#7fb3e6"
iex> Macaw.Layers.Theme.resolve(Macaw.Layers.Theme.default(), :warning, :literal)
"#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).

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, by either spelling, 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 string among its leaves is {:error, {:literal_color, value}}; under :open every color passes.

Whether name is of the theme's color table, answered without building the list.

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. 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} where it reaches 3:1 against the theme's background, {:error, {:contrast, ratio}} where it does not, and {:error, :unreadable} for a value that does not resolve.

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}} or {:surface, {:equal, :grid}}.

The contrast ink for a text drawn on under: the literal of the theme's text or background, whichever contrasts more (text on a tie), or #000000/#ffffff, whichever contrasts more, when neither reaches 4.5:1. under is a color string, a %Macaw.Layers.Color{}, or nil for the theme's background; an unreadable string is nil, and a slot whose own value does not parse 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, or a ramp, color or palette entry that breaks its contract, raises ArgumentError (§2.6).

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: the anchors spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor the anchor as written, 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.

The slot's value: the stored literal, or a CSS custom-property reference that falls back to it. Sizes are numbers under :literal and gain px under :css. A ramp position is its literal in both modes, except where a status role stands, {:ramp, :status, 0 | 0.5 | 1}, which resolves as the role while the table holds the role there. 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() :: :status | :thermal | :diverging | :sequential

One of the four ramps whose meaning the library fixes (§2.3).

ramp_position()

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

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

slot()

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

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

style_name()

@type style_name() :: atom() | String.t() | {: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: String.t(),
  background: String.t(),
  colors: %{optional(atom() | String.t()) => term()},
  dark: atom() | String.t() | nil,
  defaults: map(),
  extensions: [module()],
  font_family: String.t(),
  font_size: number(),
  grid: String.t(),
  label_size: number(),
  light: atom() | String.t() | nil,
  name: atom() | String.t(),
  ramps: %{optional(ramp_name()) => list()},
  series: [String.t(), ...],
  styles: %{optional(atom()) => map() | list()},
  surface: String.t(),
  text: String.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, by either spelling, 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, atom() | 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> theme = %{Macaw.Layers.Theme.default() | ramps: %{status: ~w(#c23238 #bd7407 #4ca34b)}}
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 string among its leaves is {:error, {:literal_color, value}}; under :open every color passes.

color_name?(theme, name)

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

Whether name is of the theme's color table, answered without building the list.

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()) :: [atom() | 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?([: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()) :: [atom(), ...]

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(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. 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, String.t()} | {:error, {:contrast, float()} | :unreadable}

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

default()

@spec default() :: t()

The light theme: dark text and axes on white.

floors(theme)

@spec floors(t()) :: :ok | {:error, [{atom(), 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}} or {:surface, {:equal, :grid}}.

ink(theme, under)

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

The contrast ink for a text drawn on under: the literal of the theme's text or background, whichever contrasts more (text on a tie), or #000000/#ffffff, whichever contrasts more, when neither reaches 4.5:1. under is a color string, a %Macaw.Layers.Color{}, or nil for the theme's background; an unreadable string is nil, and a slot whose own value does not parse is skipped.

iex> theme = Macaw.Layers.Theme.default()
iex> {Macaw.Layers.Theme.ink(theme, "#3b6fa8"), Macaw.Layers.Theme.ink(theme, nil), Macaw.Layers.Theme.ink(theme, "#777777")}
{"#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: "#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)

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, or a ramp, color or palette entry that breaks its contract, raises ArgumentError (§2.6).

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()) :: String.t()

The color at t along the theme's ramp name, as a literal: the anchors spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor the anchor as written, 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)
"#8da5cb"

ramp_names()

@spec ramp_names() :: [ramp_name(), ...]

The four ramps whose meaning the library fixes.

resolve(theme, slot, mode)

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

The slot's value: the stored literal, or a CSS custom-property reference that falls back to it. Sizes are numbers under :literal and gain px under :css. A ramp position is its literal in both modes, except where a status role stands, {:ramp, :status, 0 | 0.5 | 1}, which resolves as the role while the table holds the role there. An unknown name, or a series slot beyond the palette, raises ArgumentError.

role_names()

@spec role_names() :: [atom(), ...]

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

series_slot(theme, i)

@spec series_slot(t(), pos_integer()) :: atom()

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

slots(theme)

@spec slots(t()) :: [atom()]

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

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()) :: [atom(), ...]

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, atom() | 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.