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