Skip to main content

15 — The visual language: tokens before charts

Read this first: before you open a single chart component, you will read the four small files every chart imports — a palette, a one-hook theme resolver, a geometry file, and a formatter library. They exist so that every chart on the analytics page reads as one system instead of a pile of individual opinions, and by the end you will be able to pick the color for a new series without guessing.

Time: about 25 minutes. Assumes lesson 14.

One file owns every hue​

vizTokens.ts opens with a header comment that is the constitution for everything in this lesson:

/**
* Analytics color system — the single source of truth for every chart hue.
*
* Recharts (and three.js) consume raw hex, not Chakra tokens, so both light and
* dark values live here and are resolved at runtime by `useVizColors()`. The
* categorical order is fixed and must never be cycled or re-sorted by value —
* color encodes identity (which series), never magnitude.
*
* Values are the validated palette from the data-viz method (worst adjacent CVD
* ΔE 9.1 light / 8.4 dark; re-run scripts/validate_palette.js before changing a
* hex). Do not hand-pick colors here.
*/

Three claims in twelve lines, and each one is a rule you will use. First, the charting libraries cannot read the design system's tokens, so hex values must live somewhere as plain strings — this file is that somewhere, once, for both themes. Second, the categorical order is fixed. Third, the specific hex values are not anyone's taste: they came out of a validation script, and the comment quotes the measurement that justifies them — the two most-confusable adjacent hues still differ by a ΔE of 9.1 in light mode and 8.4 in dark, as seen by a viewer with color-vision deficiency. "Do not hand-pick colors here" is not a suggestion. If you change a hex without re-running the validator, you have no idea whether two series just became indistinguishable for one person in twelve.

The shape of the palette is a typed interface, and its doc comments carry the usage rules with it:

export interface VizColors {
/** Series identity, assigned in fixed order 0..n. Never colored by value. */
categorical: string[];
/** Single-hue magnitude ramp, low → high. Use for ordinal bands / heat. */
sequential: string[];
/** Reserved state colors — always paired with an icon + label, never alone. */
status: { good: string; warning: string; serious: string; critical: string };
gridline: string;
axis: string;
/* … text and surface colors … */
}

Two full palettes implement it — VIZ_LIGHT and VIZ_DARK — built from an 8-hue categorical array per mode and a 7-step sequential ramp per mode. Nothing else in the frontend defines a chart color.

Color encodes identity, never magnitude​

That sentence from the header is the thesis of this lesson, so slow down on it. Here are the categorical arrays:

const CATEGORICAL_LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
const CATEGORICAL_DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'];

Index 0 is blue, index 1 is orange, index 2 is green — in both modes, in every chart, on every render. A series gets its color by its identity's position, and that position never changes. You never sort a dataset by value and then color it in sorted order, because then "orange" would mean second place this month instead of the Engineering department, and a reader comparing two charts — or the same chart across two filter states — would be silently lied to.

Predict: the app already ships a Chakra brand palette with a full 50–950 shade scale. Why does the chart system not just use shades of the brand color for its series? Write your answer down before reading on.

Two reasons, and neither is aesthetic. First, shades of one hue are a sequential encoding — lighter-to-darker reads as less-to-more. Paint eight departments in eight blues and you have told the reader that departments have an order and a magnitude, which is false. Second, one hue cannot carry eight distinguishable series at all, let alone survive the CVD test the header cites — distinguishability under deuteranopia is exactly what the ΔE 9.1/8.4 figures measure, and they required eight hues engineered together. Chart color is a data channel with an encoding, not decoration. The brand palette decorates buttons; it does not encode data.

Magnitude gets its own dedicated tool — the sequential ramps:

// Low magnitude sits nearest the surface in each mode: pale on light, deep on
// dark. Both are monotone single-hue blue ramps drawn from the documented steps.
const SEQUENTIAL_LIGHT = ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b'];
const SEQUENTIAL_DARK = ['#12365f', '#184f95', '#256abf', '#3987e5', '#5598e7', '#86b6ef', '#b7d3f6'];

Notice the dark ramp is not the light ramp reversed carelessly — it is rebuilt so that low is always the step closest to the surface color. On white, low is pale; on near-black, low is deep. Either way, "barely there" means "barely anything," and the eye's reading survives the theme switch.

Mapping a number onto a ramp is one exported function, used by heat-style ordinal bands and by the 3D landscape blocks you will meet in lesson 20:

export const sequentialColor = (value: number, max: number, ramp: string[]): string => {
if (max <= 0) return ramp[0];
const t = Math.min(Math.max(value / max, 0), 1);
return ramp[Math.round(t * (ramp.length - 1))];
};

Clamped, guarded against a zero or negative max, and quantized to a real step rather than interpolated — seven distinct steps stay countable; a continuous gradient does not.

Status colors never travel alone​

The third color family is fixed state colors, and the comment above them documents a deliberate accessibility trade:

// Status is fixed — never themed. On the light surface `warning`/`serious` sit
// below 3:1 by design; the icon + label pairing is the mitigation.
const STATUS = { good: '#0ca30c', warning: '#fab219', serious: '#ec835a', critical: '#d03b3b' } as const;

good is green everywhere; critical is red everywhere; they do not shift with the theme, because a status color that changes meaning-carrying appearance between modes is a status color you cannot learn. And the comment admits two of them fail the 3:1 contrast guideline on white — then names the mitigation: the interface rule you read earlier says status is "always paired with an icon + label, never alone." A colorblind reader, or anyone squinting at warning amber on white, still gets the icon and the word. The rule: status color is redundant reinforcement, never the sole carrier of the message.

One hook resolves the theme​

Charts need the right palette at render time. useVizColors.ts is the entire bridge:

/**
* Resolves the chart palette against the active color mode. Charts read raw hex
* (Recharts can't consume Chakra tokens), so this is how a chart stays theme-aware.
* Falls back to light before hydration, when `resolvedTheme` is still undefined.
*/
export const useVizColors = (): { colors: VizColors; isDark: boolean } => {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
return { colors: isDark ? VIZ_DARK : VIZ_LIGHT, isDark };
};

It reads resolvedTheme from next-themes rather than the raw theme value, because a user whose preference is "system" has a theme of system — only resolvedTheme tells you what is actually on screen. And before hydration completes, resolvedTheme is undefined; the strict === 'dark' comparison makes that first frame render light and then correct itself, instead of crashing or flashing garbage. Chakra components never call this hook — they have semantic tokens. Anything that draws with raw hex does.

Geometry lives in one place too​

Hues are half the language; sizes are the other half. chartTheme.ts opens by saying why it exists:

/**
* Shared chart geometry. Heights, margins, and bar caps live here once so no
* chart re-invents a magic number and every chart on the page reads as one system.
*/
export const CHART_H = { xs: 180, sm: 220, md: 260, lg: 300 } as const;
export const MAX_BAR = 26;
export const AXIS_FONT = 11;

/** Vertical (horizontal-bar) chart height that grows with the category count. */
export const rankChartHeight = (rows: number) => Math.max(CHART_H.sm, rows * 36 + 48);

Four named heights instead of forty ad-hoc ones; one bar-thickness cap so a two-category bar chart does not render two comedy-width slabs; one axis font size. rankChartHeight handles the case fixed heights cannot: a horizontal ranking chart whose height must grow with its row count — 36px per row plus axis headroom, floored at the small height so a one-row chart does not collapse.

The same file packages the recurring Recharts props into spreadable bags:

/** Design-system axis/grid props for any Recharts chart, theme-aware. */
export const useChartTheme = (): ChartTheme => {
const { colors, isDark } = useVizColors();
return {
colors,
isDark,
grid: { stroke: colors.gridline, strokeWidth: 1 },
axis: {
tick: { fontSize: AXIS_FONT, fill: colors.textMuted },
axisLine: { stroke: colors.axis },
tickLine: false,
},
};
};

A chart writes <CartesianGrid {...t.grid} /> and <XAxis {...t.axis} /> and is done — recessive hairline grid, muted ticks, no tick lines, in both themes. Why prop bags of raw hex instead of CSS? The same reason as everything above, stated in the vizTokens header: Recharts cannot read Chakra tokens. It takes props, so the design system reaches it as props. How the chart wrappers consume these bags is lesson 17's story.

The formatter library​

Numbers need the same discipline as colors, and format.ts is deliberately tiny:

HelperInOutWhy it exists
pct(0.923)fraction 0–1"92%"the API returns rates as fractions; tiles want whole percents
pct1(0.923)fraction 0–1"92.3%"axes and deltas where whole numbers are too coarse
hours(v)number"1,234 h"locale thousands separators, unit attached
oneDecimal(v)number"4.2"one shared rounding rule
monthLabel(row){year, month}"Mar '26"compact axis ticks
monthTitle(row){year, month}"March 2026"drill-down drawer titles (lesson 18)
toAscendingSeries(rows)latest-first rowsascending + labelsee below

Every helper takes null | undefined and coalesces to zero, so a loading gap renders "0%" instead of "NaN%". The last one earns a quote:

/** Chronological ascending copy of a "latest-first" trend series, with x labels. */
export const toAscendingSeries = <T extends { year: number; month: number }>(rows: T[]) =>
[...rows].reverse().map((r) => ({ ...r, label: monthLabel(r) }));

The trend endpoints return rows newest-first — the natural order for a "latest value" tile. A time-series chart needs oldest-on-the-left. Rather than every chart remembering to reverse (and one forgetting, and a trend line quietly running backwards), the reversal happens once, in a helper whose name says which direction you are getting — and [...rows].reverse() copies first, because reverse() mutates and the original array belongs to the React Query cache.

Where this shows up in MotorPH​

Recap​

  • A new series takes the next unused index of categorical, in file order — color is assigned by identity, and reshuffling by value would make the same hue mean different things across renders and charts.
  • Magnitude never gets its own hue; it gets sequentialColor on a ramp — shades read as less-to-more, so using them for categories asserts an order that does not exist, and vice versa.
  • Never hand-pick a hex in vizTokens.ts — the palette's CVD ΔE figures (9.1 light / 8.4 dark) came from a validator, and an unvalidated edit can merge two series for a colorblind reader.
  • Status colors are reserved and always ship with an icon + label — two of them sit below 3:1 contrast on white by design, and the pairing is the documented mitigation.
  • Spread useChartTheme()'s prop bags instead of styling axes by hand — Recharts only takes raw hex props, and the bags are the one path that keeps every chart theme-aware and identical.

Next: 16 — Panels, tiles, and a hand-rolled sparkline.