A chart palette you can validate by machine
Most chart palettes are chosen by someone with good taste looking at swatches. That produces palettes that are lovely and occasionally unusable, because roughly one man in twelve cannot distinguish the two hues you put next to each other.
Ours is chosen the other way around: a numeric floor first, aesthetics inside what the floor allows, and a script that can re-check the whole thing. This post is about what that floor is, why the order of the array is part of the safety mechanism, and the two behavioral rules that matter more than the colors themselves.
The constraint, written down
The comment at the top of the palette module is the specification:
// The chart color system. Canvas rendering cannot read CSS custom properties,
// so both modes are spelled out as hex and the renderer picks by theme.
//
// The categorical palette is machine-validated, not eyeballed: every adjacent
// pair clears a CVD separation of ΔE >= 8 (OKLab x100) and a normal-vision
// floor of >= 15 in BOTH modes against the app's real surfaces (#ffffff and
// #141420). The dark column is the same eight hues re-stepped for the dark
// surface, not a new palette. The ORDER is part of the safety mechanism —
// do not reorder or insert hues without re-running the validator.
Four claims in there, each of which is load-bearing.
Hex, not CSS variables. The rest of the
application themes itself with custom properties. The chart engine draws to a canvas, and
canvas has no access to the cascade, so it needs literal values. Attempting to bridge
that with
getComputedStyle at
render time works and costs a layout read on every redraw, which is exactly the wrong
thing to do inside a resize handler.
Adjacent pairs, in OKLab. The metric is perceptual distance in OKLab, scaled by a hundred to give readable integers. OKLab rather than plain RGB distance because RGB distance does not correspond to how different two colors look; two greens can be far apart in RGB and nearly identical to an eye.
Against the real surfaces. Contrast is not a
property of a color, it is a property of a color on a background. The check runs against
#ffffff and
#141420, which are the
actual chart surfaces in the two themes, not against a generic white and black.
The order is the mechanism. This is the part that gets lost. A chart assigns slot 0 to the first series, slot 1 to the second, and so on, so the pairs that appear together most often are the adjacent ones. Guaranteeing separation for adjacent pairs is what makes a two-series or three-series chart safe. Insert a hue in the middle and every adjacency after it changes.
The palette
/** Categorical slots, fixed order. Validated 2026-08 on the app surfaces. */
const PALETTE_LIGHT = [
"#2a78d6", // blue
"#eb6834", // orange
"#1baf7a", // aqua
"#eda100", // yellow
"#e87ba4", // magenta
"#008300", // green
"#4a3aa7", // violet
"#e34948", // red
];
const PALETTE_DARK = [
"#3987e5", "#d95926", "#199e70", "#c98500",
"#d55181", "#008300", "#9085e9", "#e66767",
];
Blue then orange first is not arbitrary. The most common chart on this site has two series, and blue against orange is the single most robust pair in the set: it survives every form of color vision deficiency, because the confusion lines that matter run between reds and greens rather than between blue and orange.
Green sits at slot 5 and red at slot 8. Both are perfectly usable colors, and both are placed where a chart has to have six or eight series before they appear together. That placement is the whole design.
One value is identical in both columns:
#008300 at slot 6. Not
an oversight. It happened to clear the floor against both surfaces, and re-stepping it
would have pushed it into the aqua at slot 3 in one mode or the other. The dark column is
the same eight hues adjusted for a dark ground, not a second palette, so where a hue
needs no adjustment it does not get one.
The sequential ramp, and why it reverses
/** One-hue (blue) ramp for magnitude: heatmaps and sequential fills. */
const SEQUENTIAL_LIGHT = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5",
"#256abf", "#184f95", "#0d366b"];
const SEQUENTIAL_DARK = ["#0d366b", "#184f95", "#256abf", "#3987e5",
"#6da7ec", "#9ec5f4", "#cde2fb"];
Same seven colors, reversed. Magnitude should read as "more ink" and what counts as more ink depends on the ground. On white, darker is heavier. On a near-black surface, darker recedes into the background and lighter is heavier. Using the light ramp on a dark heatmap inverts the reading of the entire chart, which is the sort of bug that a screenshot review catches and a unit test does not.
A single hue, not a rainbow, because a magnitude scale should have one dimension. Rainbow ramps introduce apparent boundaries where the data has none, and they are unreadable under CVD simulation.
Rule one: never cycle hues
/** Series beyond the last palette slot fold into "Other"; never cycle hues. */
export const MAX_SERIES = PALETTE_LIGHT.length;
export const OTHER_LABEL = "Other";
The tempting implementation is
palette[i % palette.length].
It never runs out of colors and it produces charts where series 1 and series 9 are the
same blue, which is a chart that actively lies.
Folding into an Other bucket is honest. It says there were more categories than can be distinguished, and it gives them a de-emphasis gray rather than a slot, so Other never competes visually with a real series. It also happens to be the right analytical answer: a chart with fourteen categories was never readable, and the top eight plus Other is what the reader wanted.
Rule two: color follows the entity, not the rank
This is the behavior I would keep if I had to throw away everything else.
Naively, slot assignment is positional: whatever series is first in the result set gets slot 0. Which means that filtering out one series re-colors every series after it. The user filters out Asia to look more closely, and Europe changes from orange to blue. Every conclusion they had formed about the orange line is now attached to the wrong data.
/**
* Color follows the entity, never its rank: the slot map persists on the
* chart config, survivors keep their hue when a filter removes a series, and
* a new series takes the lowest slot not in use. "Other" always wears the
* de-emphasis gray, not a slot.
*/
export function assignSeriesSlots(existing, seriesNames) {
const slots = { ...existing };
const used = new Set(Object.values(slots));
for (const name of seriesNames) {
if (name === OTHER_LABEL || slots[name] !== undefined) continue;
let free = 0;
while (used.has(free)) free += 1;
if (free >= MAX_SERIES) free = free % MAX_SERIES;
slots[name] = free;
used.add(free);
}
return slots;
}
Three properties fall out of those twelve lines. A name that already has a slot keeps it, so re-rendering never re-colors. A new name takes the lowest free slot, so removing a series frees its color for the next arrival rather than shifting everyone. And Other is skipped entirely, because it has a color that is not a slot.
The map lives on the chart configuration, which is persisted with the view, so the colors also survive a reload. A chart you screenshotted on Monday looks the same on Friday.
There is one line in there I am not entirely happy with:
if (free >= MAX_SERIES) free = free % MAX_SERIES;.
That is the hue-cycling this design exists to avoid, kept as a last-resort guard for a
caller that hands in more names than the Other-folding upstream should have allowed. It
is defensive code protecting an invariant that is enforced elsewhere, and the honest
description is that it is a seatbelt for a bug rather than a feature.
Chrome is part of the palette
Series colors get all the attention and the surrounding furniture does at least as much work. It is typed, so nothing is improvised at the call site:
const CHROME_LIGHT = {
surface: "#ffffff",
ink: "#1a1a2e",
inkSecondary: "#3f3f52",
inkMuted: "#6b6b7d",
grid: "#ececf0",
axis: "#d4d4d8",
tooltipBg: "#ffffff",
tooltipBorder: "#d4d4d8",
deEmphasis: "#b9b9c4",
};
Three levels of ink, because a title, an axis label and a tick label are not equally important and drawing them all in the same color flattens the hierarchy. Gridlines one step off the surface, and the interface documents them as solid and never dashed, because dashed gridlines add visual texture that competes with the data. The de-emphasis gray is a named token rather than an inline value precisely so that Other and any context series use the same one.
Formatting, which is also a color decision
The value formatter belongs in this module for a reason: an unreadable label ruins a chart as thoroughly as an indistinguishable hue.
default:
// Auto: compact once the magnitude makes full digits noisy.
return Math.abs(value) >= 10_000 ? compact(value) : plain(value);
Ten thousand is where axis labels start colliding. Below it, full digits are more precise and fit. Above it, 12.4K is what a reader wants on an axis. The threshold is a judgment call and it is in one place, which is the part that matters.
Both branches go through
Intl.NumberFormat with
an undefined locale, so grouping separators follow the reader's machine. And the currency
branch is wrapped in a try, because
Intl throws on an
invalid currency code and a chart should degrade to plain numbers rather than disappear.
Why machine-validated matters
The value of a numeric floor is not that it produces better colors than a designer would. It is that it survives maintenance.
A palette chosen by eye degrades the first time somebody adds a ninth series color, or nudges a hue to match a new brand accent, or adds a dark mode by darkening every value by the same amount. None of those changes look wrong to the person making them, and each one can quietly break a pair.
With a floor and a validator, that change either passes or fails, and the failure names the pair. The comment saying not to reorder without re-running the validator is not bureaucracy; it is the only thing standing between this palette and a slow drift back to indistinguishable greens.
The one thing I would still like to fix: the validation date is written in a comment,
Validated 2026-08, and
a comment is not a test. It should be a check in the suite that fails on a bad edit,
sitting next to the accent-contrast tests that already run there. That is a small piece of
work and it is not done.