Skip to content

Palette resolver

For which palettes ship and what each looks like, see Palettes. This page covers how to use them.

Resolver API

from dataface.core.compile.palette import palette, color

# Sequential / diverging — returns a list of hex stops.
palette("dft-seq-blue")                     # 11 default stops
palette("dft-seq-blue", steps=5)            # 5 evenly spaced stops
palette("dft-seq-blue", reverse=True)       # reversed

# Diverging auto-skips the midpoint on even N.
palette("dft-div-blue-red", steps=6)        # flanks, no gray middle

# Categorical and scaffold — fixed slots.
palette("vivid-10")                         # all 10 slots
palette("vivid-10", steps=4)                # first 4 slots

# Named tokens — tone roles and scaffold stops.
color("negative.solid")                     # "#94001e"
color("dft-grays.gray-90")                  # "#222222"

YAML shorthand

Dashboard YAML supports a compact shorthand:

style:
  palette: "dft-seq-blue"        # 11 stops
  palette: "dft-seq-blue:5"      # 5 stops
  palette: "dft-seq-blue_r"      # reversed
  palette: "dft-seq-blue:5_r"    # 5 stops reversed
  palette: "dft-div-blue-red:6"  # 6 stops, midpoint auto-skipped
  fill: "negative.solid"
  text: "dft-grays.gray-90"

The shorthand is parsed by _parse_palette_reference(); programmatic callers should pass kwargs (steps=, reverse=) instead.

Theme-portable references vs. absolute pins

Color tokens in face YAML come in two grammars with different contracts:

# Face-root style block
style:
  charts:
    marks:
      bar:
        # Theme role — resolves through the active theme's
        # `style.palettes` bindings. Switching themes re-resolves the
        # color: on stark, `category[2]` is vivid-10's cyan; on
        # editorial, editorial-10's sky. `category_dark[2]` /
        # `category_light[2]` / `category_ghost[2]` address the
        # positional companions the same way, and named aliases like
        # `chrome.ink` resolve through the chrome role.
        border:
          color: "category_dark[2]"
      rule:
        stroke:
          # Absolute pin — names a physical palette and slot.
          # Theme-agnostic: the same hex on every theme, by design.
          color: "vivid-10.1"

Role tokens resolve everywhere colors are authored: theme YAML, the face-root style: block, and chart-level style: blocks (the theme's role bindings travel to the resolved boundary, so per-chart overrides like style.palette: ["category_dark[3]"] follow a theme switch too).

Default to theme roles when authoring faces. A face written with role references restyles itself completely on a theme switch. Reach for an absolute pin only when the color is a deliberate pick that should survive theme changes (a brand color, a semantic association like "gold means contract revenue") — pinning is a feature there, not a bug.

Indexing is 1-indexed everywhere — role brackets, absolute dot references, and the integer slot aliases inside scaffold/tone palettes all count from 1. The same physical stop carries the same slot number in both forms — on the editorial theme, category_light[7] and editorial-10-light.7 are the same gold-light color (the 7th slot).

The shipped role bindings: _base binds category / category_dark / category_light / category_ghost to the vivid-10 family (inherited by stark, dark, light); editorial and cream rebind them to the editorial-10 family. Themes may rebind any role via style.palettes, and faces can too (style.palettes is patchable at the face root).

surface="table" — WCAG-AA-safe table fills

For sequential and diverging spines, palette(name, surface="table") returns a WCAG-AA-safe sub-palette intended for table cell backgrounds behind #222 body text:

palette("dft-seq-blue", surface="table")              # 11 AA-safe stops
palette("dft-div-blue-red", surface="table", steps=5) # 5 AA-safe stops

The algorithm binary-searches the OKLCH-interpolated spine for the lightness boundary where contrast against #222222 crosses 4.5:1, then generates steps stops evenly from the light end of the spine to that boundary. The boundary is found on the continuous curve, not snapped to a spine index, so the result is dense enough for any steps value without falling off the readable edge.

surface= is not valid for categorical / scaffold / tone palettes — their slots are fixed and there is no spine to carve. Passing surface="table" on those families raises SurfaceUnsupportedError, as does any unrecognized surface= string. surface=None and surface="default" both return the standard downsample.

Smart defaults

When a dashboard doesn't name a palette, the resolver picks based on the data shape (see select_default_palette()):

Data shape Default palette
Continuous numeric, no midpoint dft-seq-blue
Continuous numeric, signed / midpoint dft-div-blue-red
Discrete enum, ≤ 10 values, editorial theme editorial-10
Discrete enum, ≤ 10 values, stark theme vivid-10
Discrete enum, > 10 values Theme default + runtime warning
Status / severity field Tone palette (negative, warning, positive)

Errors

Exception When
UnknownPaletteError Name doesn't match; message suggests the nearest hit
UnknownColorError color(token) slot unresolved
CategoricalOverrequestError steps=N > len(stops) on categorical/scaffold
SurfaceUnsupportedError surface= passed for a family that doesn't carve
ToneAsPaletteError Tone name passed to palette() instead of color()
UnsupportedPaletteWarning Anti-pattern names (RdYlGn, parula) — see Anti-Patterns below

Anti-Patterns

A handful of famous palettes routinely produce misleading charts. The resolver treats them two ways:

  • Hard fail — the name is not shipped. palette("jet") raises UnknownPaletteError.
  • Warn + alias — the name resolves to the nearest DFT equivalent and the resolver emits an UnsupportedPaletteWarning. Migration paths still work, but you'll see the nudge in logs.

Hard-fail list

Name Why rejected
jet Non-monotonic luminance creates illusory discontinuities and reverses ordering perception. Broadly criticised in the data-vis corpus (Cleveland, Cairo, Kosara). No defensible use remains.
rainbow Same problem — hue steps masquerade as ordered magnitude. Luminance wobbles.
hsv Not perceptually uniform; conflates hue and saturation. Produces banding.

There is no DFT substitute for these because their structure — rainbow, maximal hue excursion — is the anti-pattern itself. If a chart really needs distinct hues, use vivid-10 (unordered) or dft-seq-blue (ordered).

Warn-and-alias list

Name Aliased to Reason
RdYlGn dft-div-crimson-green The classic red-yellow-green diverging pair is CVD-hostile — deuteranopes can't reliably distinguish the red and green ends. dft-div-crimson-green is the only DFT palette engineered to pass CVD ΔE ≥ 11 on R/G endpoints.
parula dft-seq-blue MATLAB's default; not CVD-safe and superseded by the viridis family in most scientific tooling. dft-seq-blue is DFT's default sequential.

The warning text names the substitution explicitly so dashboards rendered from third-party tooling don't crash, but the suggestion points at the DFT alternative for new work.

References

  • Borland & Taylor, Rainbow Color Map (Still) Considered Harmful (IEEE CG&A, 2007)
  • Kenneth Moreland, Diverging Color Maps for Scientific Visualization (2009)
  • Cindy Brewer et al., ColorBrewer 2.0 — comparative palette library

Palette scoring

The Colorgorical scoring helper scores any palette on four axes — a re-implementation of the Colorgorical methodology (Gramazio, Laidlaw, Schloss, IEEE TVCG 2017) against the public XKCD color-name survey (CC0) and the Schloss-Palmer 2011 pair-preference regression.

Score What it measures
Perceptual Distance CIEDE2000 ΔE between color pairs. Same metric as the Leonardo CVD gate.
Name Difference Whether two colors land under different XKCD names — "can someone say 'the blue one' vs 'the green one'?"
Name Uniqueness Whether a color unambiguously falls under one name, or sits on a boundary (cyan/teal, slate/charcoal). Higher = clearer.
Pair Preference Schloss-Palmer regression (lightness contrast + hue-angle difference). Which pairs look good side-by-side in a legend.

The primary gate remains Leonardo ΔE ≥ 11 for CVD safety. Colorgorical scoring is a secondary quality signal — a palette that passes Leonardo is CVD-safe; passing the scoring functions says it is also preferable and legend-readable.

Known simplifications vs the Colorgorical paper (deliberate; per the Colorgorical paper):

  • Name Difference / Uniqueness use the aggregated XKCD 949-centroid list, not the full 2.8M-response probability distributions from Heer-Stone
  • Colors near naming boundaries score worse than they would under the paper's full model.
  • Pair Preference omits Schloss-Palmer's coolness term (requires a 5MB precomputed LAB→coolness lookup derived from their raw data). We keep the hue + lightness regression with the published coefficients (wh=-46.4222, wl=47.6133).

Scores are therefore mutually comparable across DFT palettes, but not a bit-exact reproduction of Colorgorical's paper numbers.

Versioning

Once a palette spine is committed, its hex values are immutable. Re-tunes ship under a new name (dft-seq-blue-v2, say) so existing dashboards don't silently shift. Bug fixes (e.g., out-of-gamut typos) are the one exception — documented in PALETTE_CHANGELOG.md when that happens.