Skip to content

Palette System (M2)

Status: shipped Scope: DFT chart-library palette resolver — sequential, diverging, categorical, scaffold, semantic Source: Color Palettes, Default Categorical Palette, Palette Anti-Patterns, ai_notes/palette_studio/session5_spec.md

This guide covers the M2 palette resolver — the runtime that reads per-palette YAML files under dataface/core/defaults/palettes/ and produces lists of sRGB hex stops for charts. The research behind the palettes themselves lives in Color Palettes and the session notes under ai_notes/palette_studio/; this page is the practical API reference.

Families

Every palette belongs to one of five families. The family is encoded in the directory the palette ships from and determines how the resolver treats it.

Family Purpose Directory Example
sequential Ordered magnitude (light → dark) palettes/sequential/ dft-seq-blue
diverging Signed values around a midpoint palettes/diverging/ dft-div-blue-red
categorical Unordered categories palettes/categorical/ category-10, hero-6, category-6-tonal-{blue,green,purple,orange,brown}
scaffold UI structural framing, not data palettes/scaffold/ dft-grays, dft-creams
semantic Role tokens (danger, success) palettes/semantic/ danger, warning, success, info

Scaffolds and semantics are UI structure, not data encoding. They use named slots (gray-90, solid, etc.) instead of numbered stops.

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
palette("dft-seq-blue", surface="table")    # light half only, WCAG-safe

# 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("category-10")                      # all 10 slots
palette("category-10", steps=4)             # first 4 slots

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

YAML shorthand

Dashboard YAML supports a compact shorthand:

style:
  palette: "dft-seq-blue"        # 11 stops, default surface
  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: "danger.solid"
  text: "dft-grays.gray-90"

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

Surface variants — surface="table"

Sequential and diverging palettes ship with a table surface variant — the subset of the 120-stop LUT whose stops pass WCAG AA (≥ 4.5 : 1) for #222222 text. This is computed per palette at build time by scripts/expand_palette_lut.py, not hand-authored. Palettes that can't produce a safe carve fail the build.

Categorical, scaffold, and semantic palettes don't support surface variants; surface= raises SurfaceUnsupportedError.

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 category-10
Discrete enum, > 10 values category-10 with a runtime warning
Status / severity field Semantic palette (danger, warning, success, info)

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
SemanticAsPaletteError Semantic name passed to palette() instead of color()
UnsupportedPaletteWarning Anti-pattern names (RdYlGn, parula) — see anti-patterns

Palette scoring

scripts/palette_scoring.py 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.

Run it on any palette file or ad-hoc hex list:

uv run python scripts/palette_scoring.py \
  --palette-file dataface/core/defaults/palettes/categorical/category-10.yml

uv run python scripts/palette_scoring.py --palette "#1f77b4,#ff7f0e,#2ca02c"

The primary gate remains Leonardo ΔE ≥ 11 for CVD safety via scripts/palette_deltae_checker.py. 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.

For the category-10 diagnostic, see ai_notes/considerations/DFT_CATEGORY_10_COLORGORICAL_DIAGNOSTIC_2026.md.

Known simplifications vs the Colorgorical paper (deliberate; see the script docstring):

  • Name Difference / Uniqueness use the aggregated XKCD 949-centroid list, not the full 2.8M-response probability distributions from Heer-Stone 2012. 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.

Migration from legacy names

Old name New name Migration
config.fivetran_grays config.dft_grays or color("dft-grays.gray-90") Rename attribute or switch to color()
config.fivetran_cream_scaffold_grays config.dft_creams or color("dft-creams.cream-90") Same
inline palettes.category-10 unchanged — get_palette("category-10") or palette("category-10") No change needed

The old fivetran_grays / fivetran_cream_scaffold_grays block has been removed; config.dft_grays and config.dft_creams are loaded from the per-family YAMLs under defaults/palettes/scaffold/.