Charts
A categorical palette, a sequential ramp and a diverging ramp, each checked by the build. Twenty files currently pick their own chart colours.
The series palette
Assigned in this fixed order and never cycled. Colour follows the entity, not its rank, so a filter that drops a series must not repaint the ones that remain.
--p234-color-chart-series-1
Blue. Series are assigned in this fixed order and never cycled: colour follows the entity, so a filter that drops a series must not repaint the survivors.
--p234-color-chart-series-2
Ochre.
--p234-color-chart-series-3
Magenta. The first three are the mutually distinct set, safe when series are compared through a legend rather than side by side.
--p234-color-chart-series-4
Green. From here on only ADJACENT separation is guaranteed, so four or more series need direct labels or faceting.
--p234-color-chart-series-5
Purple. The last slot. A sixth series folds into Other, or the chart becomes small multiples.
How far the palette actually goes
This is the part worth reading before building a chart with more than three lines on it.
Six mutually distinct hues, inside the lightness band, each clearing 3:1 against the page, do not exist. That is measured, not asserted: searching the usable colour space, the best achievable worst pair was
| 3 series | CVD 15.3 | normal 26.3 · comfortable | |
| 4 series | CVD 7.4 | normal 17.5 · below the 8 target | |
| 5 series | CVD 7.0 | normal 13.4 · below the 15 floor, a hard fail | |
| 6 series | CVD 5.2 | normal 10.2 · not usable |
So the system says what is true rather than shipping six and hoping:
- Up to three series: use slots 1 to 3. They are mutually distinct, so they stay readable in a legend, in a scatter, or anywhere a reader compares non-adjacent marks.
- Four or five series: slots 4 and 5 guarantee separation from their neighbours only. That is the correct rule for lines, bars and stacked areas, where the marks that touch are the ones confused. Direct labels become mandatory, not optional.
- Six or more: there is no sixth colour. Fold the tail into Other, facet into small multiples, or index the series to a common base and show fewer.
Both rules are enforced at build time by checkSeries. The measured margins today: adjacent 9.4 in light and 8.0 in dark against a target of 8; the first three at 11.2 and 9.8 CVD, 20.2 and 19.3 for full colour vision against a floor of 15.
A chart, with the rules applied
Three series: 2px lines, recessive grid, direct labels, legend present
Petrol price by state
Reported pump prices, May to November 2026. Official NBS figures lag by about four months.
HTML
<div style="max-width:40rem">
<p class="p234-kicker" data-section="markets">Petrol price by state</p>
<svg viewBox="0 0 560 220" role="img" aria-label="Petrol price by state, Lagos, Abuja and Kano, May to November 2026" style="width:100%;height:auto">
<g stroke="var(--p234-color-chart-grid)" stroke-width="var(--p234-chart-grid-width)">
<line x1="40" y1="30" x2="470" y2="30"></line>
<line x1="40" y1="75" x2="470" y2="75"></line>
<line x1="40" y1="120" x2="470" y2="120"></line>
<line x1="40" y1="165" x2="470" y2="165"></line>
</g>
<line x1="40" y1="190" x2="470" y2="190" stroke="var(--p234-color-chart-axis)" stroke-width="1"></line>
<g fill="var(--p234-color-ink-muted)" font-family="var(--p234-font-family-text)" font-size="11">
<text x="34" y="34" text-anchor="end">1400</text>
<text x="34" y="79" text-anchor="end">1300</text>
<text x="34" y="124" text-anchor="end">1200</text>
<text x="34" y="169" text-anchor="end">1100</text>
<text x="40" y="205">May</text>
<text x="255" y="205" text-anchor="middle">Aug</text>
<text x="470" y="205" text-anchor="end">Nov</text>
</g>
<g fill="none" stroke-width="var(--p234-chart-line-width)" stroke-linejoin="round" stroke-linecap="round">
<polyline points="40,170 126,150 212,118 298,96 384,62 470,40" stroke="var(--p234-color-chart-series-1)"></polyline>
<polyline points="40,176 126,162 212,134 298,120 384,88 470,70" stroke="var(--p234-color-chart-series-2)"></polyline>
<polyline points="40,182 126,174 212,152 298,140 384,112 470,96" stroke="var(--p234-color-chart-series-3)"></polyline>
</g>
<g font-family="var(--p234-font-family-text)" font-size="11" font-weight="600">
<text x="478" y="44" fill="var(--p234-color-chart-series-1)">Lagos</text>
<text x="478" y="74" fill="var(--p234-color-chart-series-2)">Abuja</text>
<text x="478" y="100" fill="var(--p234-color-chart-series-3)">Kano</text>
</g>
</svg>
<p style="font-family:var(--p234-type-caption-family);font-size:var(--p234-type-caption-size);color:var(--p234-color-ink-subtle);margin-top:var(--p234-space-3)">Reported pump prices, May to November 2026. Official NBS figures lag by about four months.</p>
</div>Note what the example does not do: it puts no number on every point, it does not colour the axis labels to match the series, and the series names sit at the end of each line so identity never depends on colour alone.
Chart furniture
--p234-color-chart-axis
Axis line and ticks. Non-text, needs 3:1.
--p234-color-chart-grid
Grid lines, deliberately recessive. Not gated at 3:1 because a grid that competes with the data is a worse chart.
--p234-color-chart-reference
Dashed reference lines: a target, an official figure, a prior period.
--p234-color-chart-tooltip-bg
--p234-color-chart-tooltip-ink
--p234-color-chart-tooltip-border
The grid is the one colour in the system deliberately not gated at 3:1. A grid that competes with the data is a worse chart, so it is allowed to be faint.
Mark specs
--p234-chart-line-width | 2px | ||
--p234-chart-grid-width | 1px | ||
--p234-chart-marker-size | 8px | Minimum hit and read size for a point marker. | |
--p234-chart-area-opacity | 0.14 | Fill under a line. Low enough that an overlapping pair stays legible. | |
--p234-chart-mark-gap | 2px | Surface-coloured gap between adjacent bars and stacked segments. | |
--p234-chart-radius-end | 4px | Rounded data end on a bar, anchored to the baseline. The baseline end stays square. |
Sequential, for magnitude
One hue, light to dark. This is what the Nigeria choropleth reads, and what any "how much" map or heatmap uses. Never a rainbow: a rainbow implies categories where there is only quantity.
--p234-color-chart-seq-1
Sequential ramp for magnitude, one hue light to dark. This is what the Nigeria choropleth reads. Never a rainbow.
--p234-color-chart-seq-2
--p234-color-chart-seq-3
--p234-color-chart-seq-4
--p234-color-chart-seq-5
The build checks that lightness is monotonic and that each step is at least 0.06 apart, so the steps stay tellable from one another when the map shrinks to phone width.
Diverging, for polarity
Two poles and a neutral midpoint, for data where the sign matters as much as the size: a swing, a margin, a change against a prior period.
--p234-color-chart-div-1
Diverging ramp for polarity: warm pole, neutral midpoint, cool pole. For swings and margins, where the sign matters as much as the size.
--p234-color-chart-div-2
--p234-color-chart-div-3
The neutral midpoint. Never a hue: a coloured midpoint invents a category that is not in the data.
--p234-color-chart-div-4
--p234-color-chart-div-5
The midpoint is never a hue. A test fails the build if it picks up chroma, because a coloured midpoint invents a category that is not in the data.
Rules
- A legend is present for two or more series; four or fewer are also direct-labelled.
- Text wears text tokens. Values, axis labels and captions stay in ink or ink-muted; the coloured mark beside them carries identity.
- Offer the numbers as a table as well. For an aggregated price series this is often the thing the reader actually wanted.
- Never a dual axis. Two measures on different scales become two charts, small multiples, or both indexed to a common base.
- Never reuse a status colour as a series colour. Success and error mean state, not identity.
- Never generate a sixth hue at runtime.
- Never label every point. Label the ends, the peak, and the point being discussed.
Dark is chosen, not flipped
Every dark step was re-selected against the dark page and re-validated from scratch. An automatic lightness flip puts colours outside the dark band, which is narrower than the light one: 0.48 to 0.67 against 0.43 to 0.77.
One finding worth recording: teal is not available to this system. At every lightness that clears the band, teal sits below the 0.10 chroma floor and reads as grey, so it was dropped from the hue families rather than shipped as a colour nobody can name.