Design rationale¶
This is the design system’s evidence page: why the colors — and eventually the
typography — are built and gated this way. The dc.* color system is not a
hand-picked table of hex codes. Every palette color, every colormap, and every
cycle is generated deterministically from 107 numbers by a small set of
perceptual axioms, and each output is checked against color-science gates
before it ships.
To use the color surfaces directly, see Colors,
Palettes, Colormaps, and
Color class. Every figure below is rendered live from the
shipped package by docs/color_system/generate_theory_figures.py, so the
pictures are the proof.
Three principles¶
1 · Perceptual-first. Colors are designed on coordinates built for human vision, not on RGB or HSV. Lightness lives on CIELAB L*; hue and chroma are manipulated in OKLCH. “Even in the numbers” and “even to the eye” are made to agree.
2 · Generative, not tabular. Colors are not stored; they are computed. Each family is defined by a handful of numbers, and a compiler turns those numbers into a 10-step ladder. Consistency is enforced (every family passes the same rules), extension is principled (a new hue axis is a point on a curve, not a guess), and the design is verifiable (the per-family values lie on smooth low-order curves — evidence that it is one curve, not fifteen arbitrary choices).
3 · Honest guarantees. Color is a field of trade-offs: a palette that is perfect under one metric necessarily compromises under another. v5 does not hide this — it publishes three metrics and states, for each output, what it guarantees and what it does not. A claim like “accessible” or “perceptually uniform” is always qualified by under which metric.
The perceptual foundation (axiom A1)¶
A1 — lightness is CIELAB L* (D65); hue and chroma are OKLCH; out-of-gamut colors are mapped the CSS Color 4 way (hold L* and hue, shrink chroma); every gate is judged in a ΔE metric.
Human lightness perception responds to physical luminance logarithmically (Weber–Fechner). Even steps in luminance Y bunch up at the light end; even steps in CIELAB L* form a perceptually even staircase.¶
Why L* for lightness — “log spacing” is already built in. A common instinct is to space color steps logarithmically rather than linearly. That instinct is already satisfied: L* is the log-compressed coordinate (L*50 ≈ 18% of white’s luminance). Even spacing on L* is therefore already even to the eye — applying a second log on top would double-compress and make the ramp less uniform.
Why OKLCH for hue and chroma. CIELAB is excellent for lightness but distorts hue manipulation (brightening a blue drifts it toward purple). OKLCH (Ottosson, 2020) holds hue constant while lightness and chroma move independently, so a ladder keeps its color identity. The roles are split: CIELAB L* measures lightness, OKLCH does the manipulation.
Gamut mapping. When an OKLCH request falls outside sRGB, naive clipping skews the hue. The CSS Color 4 method holds L* and hue fixed and shrinks chroma by bisection until the color is back in gamut.
The generation axioms (A2–A8)¶
Each axiom is a testable rule, recorded together with its adoption evidence (curve fit) and the alternative it beat.
A2 · hue-specific lightness floor¶
Every family starts at L*96 and descends to a hue-specific floor. The floor is not the gamut wall — it is a perceptual design value defined by a Fourier curve
floor(h).
Each hue goes only as dark as the color survives: yellow stops near L*60 (darker turns to olive mud), violet reaches L*37 and is still violet. This is why the palette reads bright and crisp — dragging every family down to a shared minimum would muddy the warm hues.¶
The rejected alternative — “floor = the gamut wall where chroma has fallen
to λ× its peak” — was physically elegant but missed the design floors by
RMSE 15 L*. The accepted model puts floor(h) on a Fourier (k=3) curve
that fits the family floors to RMSE 0.77 L*: the floor is a smooth
function of hue, and a new family’s floor is read straight off the curve.
A4 · the drift power law¶
Hue rotates as a function of lightness:
h(t) = h₀ + Δh · t^γ. As colors darken, warm hues rotate a lot (toward flame-like orange/red), cool hues a little.
Yellow rotates Δh −46° (bright lemon → dark amber); blue rotates only +15°.
γ controls when the rotation happens (γ>1 accelerates it in the dark
end). One power law captures every family’s drift to wRMSE ≤ 1.7°. This
drift is what keeps dark colors vivid instead of muddy.¶
A5 · step placement is perceptual even-spacing¶
The 10 steps are re-placed along the color path until neighbor ΔE is uniform. Spacing is generalized by a warp function
w(t); the default is linear (perceptually even), withease/exp/logavailable as options.
Equalized spacing flattens the neighbor ΔE so that step-number difference =
perceptual difference — index arithmetic becomes meaningful (blue3↔blue5
covers the same perceptual distance as blue6↔blue8). Naive linear-t
sampling leaves the spacing uneven.¶
Even spacing is the default because it makes data visualization
predictable. It is not the only valid answer: UI and document design often
want more resolution at the ends (background pastels, emphasis darks), which
an S-curve (ease) provides — so the spacing is left open as a warp option
while the default stays linear.
A6 · the achromatic exception (gray)¶
Gray alone uses an even L* ladder (96→28) with a faint cool tint
(h250, C ≤ 0.011). Having no hue identity, it is exempt from the drift and
chroma-fingerprint rules; even L* alone keeps its neighbor ΔE uniform. Gray
is reserved for grids, reference lines, benchmarks, and “other” categories —
it is deliberately not part of Octave (dc.octave); Octave Print adds a
dark gray as its 8th color for B&W lightness spread.
A7 · the hard gates¶
Verified automatically at compile time; an output that fails cannot ship.
Gate |
Criterion |
Metric |
|---|---|---|
L* monotonic |
strictly light→dark within a family |
CIELAB L* |
Equalization uniformity |
neighbor-ΔE coefficient of variation cv ≤ 0.08 (palette ladders only — sequential-cmap cv is measured, not hard-gated) |
OKLab ΔE |
Categorical accessibility |
common CVD (normal + protan + deutan) min ΔE ≥ 10; rare tritan ≥ 8 (accurate Brettel-1997 model) |
CIEDE2000 |
Sequential-cmap gray monotonic |
monotonic even under luminance conversion |
CIELAB L* |
A8 · colormaps do not inherit the palette floors¶
Heatmap sequential colormaps are generated on a wide L* range (96 → ~20), not on the family’s hue-specific floor.
A palette ladder stops at its hue floor, so different families span
different L* ranges — fine for categorical swatches, wrong for a heatmap
where dynamic range and cross-panel comparability matter. Colormaps are
regenerated over a shared wide range instead. aurora holds monotonic,
near-linear L* (ΔE cv 0.063, L* range 81.9) against viridis (cv 0.086,
76.0) on the same 32-stop measurement of the shipped 256-LUT.¶
Anatomy of a family¶
The whole system regenerates from 107 numbers: 19 chromatic families × 4 free
parameters (76) + 24 Fourier coefficients for the global hue curves + 7
constants. A family’s four free numbers are h₀ (hue anchor), Δh (total
drift), γ (drift timing), and t_p (chroma-peak position); the other four
(C_max, floor, c₀, c_end) are derived from the global curves.
The eight numbers behind yellow, and the trajectories they drive: L*
descends to the floor, chroma rises to its peak at t_p, and the hue drifts
by Δh on the γ schedule.¶
The confirmed parameters, rounded to a human-readable grid (rounding moves a palette by mean ΔE 0.5–1.4, below the 8-bit hex quantization floor):
family |
h₀ |
Δh |
γ |
t_p |
C_max |
floor |
c₀ |
c_end |
|---|---|---|---|---|---|---|---|---|
|
16 |
+11 |
1.10 |
0.85 |
0.210 |
42 |
0.10 |
0.90 |
|
3 |
+14 |
1.00 |
0.85 |
0.210 |
40 |
0.10 |
0.85 |
|
27 |
+2 |
1.15 |
0.85 |
0.205 |
44 |
0.10 |
0.90 |
|
52 |
−12 |
1.20 |
0.85 |
0.195 |
49 |
0.15 |
0.95 |
|
77 |
−41 |
1.30 |
0.85 |
0.190 |
54 |
0.15 |
1.00 |
|
88 |
−44 |
1.40 |
0.65 |
0.185 |
57 |
0.15 |
1.00 |
|
99 |
−46 |
1.50 |
0.45 |
0.180 |
60 |
0.15 |
1.00 |
|
122 |
+11 |
0.60 |
0.45 |
0.190 |
56 |
0.15 |
0.85 |
|
149 |
−3 |
0.60 |
0.50 |
0.185 |
51 |
0.15 |
0.75 |
|
176 |
−13 |
0.60 |
0.45 |
0.155 |
47 |
0.15 |
0.70 |
|
202 |
+13 |
0.85 |
0.45 |
0.115 |
44 |
0.15 |
0.75 |
|
220 |
+14 |
0.85 |
0.60 |
0.130 |
43 |
0.15 |
0.80 |
|
238 |
+15 |
0.85 |
0.75 |
0.165 |
42 |
0.15 |
0.85 |
|
256 |
+5 |
1.25 |
0.80 |
0.190 |
40 |
0.15 |
0.85 |
|
273 |
−5 |
1.65 |
0.85 |
0.210 |
39 |
0.10 |
0.85 |
|
298 |
−12 |
1.25 |
0.85 |
0.230 |
37 |
0.10 |
0.85 |
|
319 |
+0 |
1.00 |
0.75 |
0.220 |
37 |
0.05 |
0.85 |
|
335 |
+9 |
0.95 |
0.80 |
0.210 |
37 |
0.05 |
0.85 |
|
350 |
+18 |
0.85 |
0.85 |
0.210 |
39 |
0.05 |
0.85 |
grayfollows the A6 rule (L* 96→28, cool tint h250). A new family needs only its four free numbers — the rest is read off the global curves.
The metric system — what the ruler is¶
A color system’s credibility depends on which ruler measures distance. v5 uses three, each for what it is best at:
Purpose |
Metric |
Why |
|---|---|---|
Equalization & design |
OKLab ΔE |
the space the recipe lives in; built for gradient uniformity |
Accessibility gate |
CIEDE2000 |
the industry-standard discrimination metric, calibrated against the Okabe-Ito benchmark |
Lightness & grayscale guarantee |
CIELAB L* |
matches physical print and grayscale conversion |
The single most important correction in v5. An earlier design measured
accessibility with ΔE76 (CIELAB Euclidean), which over-states the distance
between high-chroma colors. blue7 and violet7 are two clear colors in
normal vision but nearly merge under a deuteranopia simulation. ΔE76 scores
them well above the gate threshold — a false pass — while CIEDE2000 scores
them below it and correctly fails. An adversarial critique caught this; the
gate was switched to CIEDE2000 and the cycle re-searched from scratch.¶
All three coefficients of variation are published on the palette diagnostic card. No step placement drives all three to zero simultaneously — being explicit about what is guaranteed is the honest design (principle 3).
The CVD simulation behind the accessibility gate is itself chosen for
accuracy per deficiency. The common red-green deficiencies (protanopia,
deuteranopia) use the Machado (2009) model; the rare S-cone deficiency
(tritanopia) uses the physiologically accurate Brettel-Viénot-Mollon (1997)
projection, because Machado’s fitted tritan matrix over-states blue-yellow
separation. Under the accurate model a seven-hue cycle’s tritan separation
tops out near 9 — so, rather than claim a number the colors cannot meet, the
gate is tiered: the common deficiencies are held to ≥ 10 and the rare
tritan to a realistic ≥ 8. dc.octave measures 10.3 (common) / 8.3 (tritan)
for Octave; dc.octave_print, 10.4 / 9.8 for Octave Print. Octave Print is
hue-parallel with Octave — same hue per slot, with the violet slot matching
and the dark gray anchor in slot eight — while keeping every pair at least
about 7 L* apart (min ΔL* 7.7) for grayscale. Publishing the real floors
instead of a flattering worst-case is the same honest-guarantees principle at
work.
Colormaps, derived from the palette¶
The colormaps are made by the same generative system (perceptual coordinates + recipe + ΔE equalization + gates): 43 continuous maps across four families, plus the two palette cycles registered as qualitative maps. A colormap is not a separate color vocabulary — it is derived from the palette, and the naming grammar enforces that relationship.
The full catalog: 20 single-hue ramps, 9 multi-hue scenes, 11 diverging pairs, three cyclic maps, and the two qualitative cycle registrations.¶
One naming grammar. The name states color identity; the suffix states a variant.
Kind |
Rule |
Example |
|---|---|---|
color token |
|
|
categorical cycle |
|
|
single-hue cmap |
the family name itself |
|
multi-hue cmap |
a natural-light scene name |
|
diverging cmap |
a |
|
cyclic cmap |
a circular-light-phenomenon name |
|
qualitative cmap |
stable public cycle token |
|
variant suffix |
|
|
Direction — an ink/light metaphor. Ink maps (single-hue, diverging)
follow the “ink on white paper” metaphor: high value = darker. Light
maps (multi-hue, cyclic) follow the “light out of darkness” metaphor:
high value = brighter (the viridis convention). This replaces matplotlib’s
unprincipled mix of directions (Blues runs light→dark, viridis runs
dark→light) with an explicit rule; reverse any map with _r.
Anchor-graph coherence. The 15 family anchors (h₀) are the system’s
only hue vocabulary. A single-hue map renders one anchor; a diverging map
joins an anchor pair; a multi-hue map traces a path through anchors; a
cyclic map is a closed loop back to its start. Palette, cycle, and colormap
all live on one graph.
The multi-hue scenes¶
Multi-hue maps traverse several hues to maximize perceptual resolution. Because they cannot be named after one family, they take scene names — but grounded in the dartwork theme “the natural-light scene where that color path actually appears,” with hue way-points chosen only at family anchors:
Name |
Scene |
Anchor path |
Analogous to |
|---|---|---|---|
|
polar light |
violet→indigo→sky→teal→lime→yellow |
viridis |
|
sunset afterglow |
violet→purple→pink→red→orange |
plasma |
|
flame |
violet→pink→red→orange→yellow |
magma |
|
lava glow (no violet) |
red→orange→amber→yellow |
hot |
|
lagoon water |
blue→cyan→teal→green→lime |
— |
|
glacier-crevasse light |
indigo→blue→sky→cyan→teal |
ice |
|
forest-canopy light |
teal→green→lime→yellow |
algae |
|
misty dawn (low-chroma, CVD-optimal) |
blue→sky→green→yellow |
cividis |
|
wide-band spectrum |
violet→blue→cyan→green→yellow→orange |
Spectral |
aurora earns the default: against viridis it is ~1.3× as uniform (ΔE cv
0.063 vs 0.086) over a wider L* range (81.9 vs 76.0), measured identically
at 32 stops on the shipped 256-LUT.
The cyclic maps¶
Angle and phase data (0° = 360° — CFD phase fields, FFT phase, wind
direction) drawn with an ordinary sequential map grow a false
discontinuity at the 0↔1 seam. A cyclic map starts where it ends, so the
seam vanishes. halo and corona are double-lobed lightness loops; hue
is an isoluminant hue wheel. The L* monotonicity gate does not apply to
cyclic maps; a seam-continuity gate does instead.¶
What this system does not guarantee¶
Stated plainly, per principle 3:
Grayscale separation of diverging maps. Both arms converge to the same lightness — the essence of a symmetric map. Grayscale print needs contours, hatching, or numbers alongside.
Body-text contrast of the yellow family. 4.5:1 on white is structurally impossible for yellow (true of every color system); each family’s “text-safe step” is marked on its diagnostic card.
Cross-family orthogonality of the warm dark steps.
yellow9,amber9, andorange8converge in a narrow hue corridor (a geometric consequence of the drift aesthetic); the family names’ color identity is reliable through steps 0–7.On-screen perceived brightness. The L* gate is a luminance (print) criterion; the Helmholtz–Kohlrausch effect makes saturated dark steps read brighter than an equal-L* gray.
Dark backgrounds. Every gate assumes a white background in v5; a dark variant is planned for v5.1.
turbo-style high-contrast rainbows. Their apparent “detail” comes from a non-monotonic L* — the very flaw that invents structure that is not in the data (an A7 violation).
irisis the widest monotonic hue path possible.
Reproducibility¶
The machine-readable SSOT (all 91 parameters, the palette, the cycles, and the 43-map catalog) lives beside the design record:
docs/superpowers/specs/2026-07-03-color-system-v5-design-rationale.md— the full design rationale.docs/superpowers/specs/assets/2026-07-03-color-system-v5/color_v5_ssot.json— the parameter/palette/cmap SSOT.docs/color_system/generate_theory_figures.py— regenerates the figures above from the shipped package (run only when the SSOT changes).
See also¶
Colors — the generated
dc.*families as swatch sheets.Palettes — pick a cycle by intent.
Colormaps — the 43-map catalog, applied.
Color class — the
Colorclass and custom colormaps.
Typography rationale¶
The font system now follows the same evidence pattern as color: families are not bundled because they look plausible in a specimen sheet. Each family has one chart job, every measured claim is derived from the shipped font files, and the fallback chain is treated as product behavior rather than an accident of matplotlib configuration.
T1 · Jobs before taste. Every registered matplotlib family has exactly one documented role: body, display, Korean body, serif, monospace, Korean monospace, or fallback tail. A new family must do a job that another bundled family does not already do better.
T2 · Measured gates. OS/2 weights, matplotlib-safe numeric axes, browser-only tabular-numeral availability, fixed-width truth, chart-glyph coverage, Hangul coverage, and license class are read from the bundled files in tests and docs. Known upstream metadata quirks are named in the registry instead of being silently normalized.
T3 · Roles in user space. Presets and docs speak in roles, while users keep
native matplotlib Figure / Axes objects. Size and weight still flow through
dm.fs() and dm.fw(), so swapping presets does not strand literal point
sizes or arbitrary font weights.
T4 · Fallback is pinned. The base text chain is
Roboto → Inter → Paperlogy → Noto Sans CJK KR → Pretendard → Noto Sans Math → Noto Sans Symbols → Noto Sans Symbols 2 → sans-serif. Tests pin the first
family that resolves every guaranteed chart glyph, the digits, and 한.
Role |
Default |
Alternates |
Job |
|---|---|---|---|
body |
Roboto |
Inter · IBM Plex Sans · Source Sans 3 · Noto Sans |
neutral Latin chart text and editorial alternates |
display |
Inter Display |
- |
large titles, headings, poster-scale numerals |
kr-body |
Paperlogy |
Pretendard · Noto Sans CJK KR |
Korean and CJK labels without system-font dependence |
serif |
Source Serif 4 |
- |
opt-in serif body for journal- or book-matched figures (not in any preset chain) |
mono |
JetBrains Mono |
IBM Plex Mono · Roboto Mono · Source Code Pro |
code, timestamps, aligned labels, tabular data |
mono-kr |
D2Coding |
- |
monospaced Hangul for code blocks and aligned Korean tables |
fallback-tail |
Noto Sans Math |
Noto Sans Symbols · Noto Sans Symbols 2 |
math operators, arrows, signs, and dingbats |
Source Serif 4 is an opt-in family: it is not wired into any preset fallback
chain, so a serif figure asks for it explicitly with
plt.rcParams["font.family"] = "Source Serif 4". No Korean serif (명조) is
bundled — a legible Hangul serif would add several megabytes, so KR serif is
out of scope by design. For monospaced Hangul (code blocks, aligned Korean
tables) set font.family = ["JetBrains Mono", "D2Coding"] so both scripts stay
fixed-width.
| Family | Role | Weights | Aligned digits | tnum available | Chart glyphs | Hangul | License |
|---|---|---|---|---|---|---|---|
| Roboto | body | 100, 200, 300, 400, 500, 600, 700, 800, 900 | digits | yes | 7/8 −×±°μσΔ | - | OFL-1.1 |
| Inter | body | 100, 200, 300, 400, 500, 600, 700, 800, 900 | - | yes | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| IBM Plex Sans | body | 100, 200, 300, 400, 500, 600, 700 | digits | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| Source Sans 3 | body | 200, 300, 400, 500, 600, 700, 900 | digits | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| Noto Sans | body | 100, 200, 300, 400, 500, 600, 700, 800, 900 | digits | yes | 7/8 −×±°μσΔ | - | OFL-1.1 |
| Inter Display | display | 100, 200, 300, 400, 500, 600, 700, 800, 900 | - | yes | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| Paperlogy | kr-body | 250, 300, 400, 500, 600, 700, 800, 900 | digits | - | 5/8 −×±→° | yes | OFL-1.1 |
| Pretendard | kr-body | 100, 200, 300, 400, 500, 600, 700, 800, 900 | - | yes | 8/8 −×±→°μσΔ | yes | OFL-1.1 |
| Noto Sans CJK KR | kr-body | 400 | digits | - | 3/8 ×±° | yes | OFL-1.1 |
| Source Serif 4 | serif | 200, 300, 400, 600, 700, 900 | digits | yes | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| Noto Serif | serif | 100, 200, 300, 400, 500, 600, 700, 800, 900 | digits | yes | 7/8 −×±°μσΔ | - | OFL-1.1 |
| IBM Plex Serif | serif | 100, 200, 300, 400, 500, 600, 700 | digits | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| JetBrains Mono | mono | 100, 200, 300, 400, 500, 600, 700, 800 | mono | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| IBM Plex Mono | mono | 100, 200, 300, 400, 500, 600, 700 | mono | - | 5/8 −×±→° | - | OFL-1.1 |
| Roboto Mono | mono | 100, 200, 300, 400, 500, 600, 700 | mono | - | 7/8 −×±°μσΔ | - | OFL-1.1 |
| Source Code Pro | mono | 200, 300, 400, 500, 600, 700, 900 | mono | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| D2Coding | mono-kr | 400, 700 | mono | - | 8/8 −×±→°μσΔ | yes | OFL-1.1 |
| Noto Sans Math | fallback-tail | 400 | digits | - | 8/8 −×±→°μσΔ | - | OFL-1.1 |
| Noto Sans Symbols | fallback-tail | 400 | digits | - | 4/8 −×→° | - | OFL-1.1 |
| Noto Sans Symbols 2 | fallback-tail | 400 | digits | - | 3/8 −×° | - | OFL-1.1 |
In the matrix, Aligned digits means the default digit advances are uniform in
real matplotlib output, or the family is fixed-width. tnum available is a
browser/specimen signal only: Inter and Pretendard expose the OpenType
feature, but matplotlib does not apply it, so use IBM Plex Sans, Source Sans 3, Paperlogy, Noto Sans, Roboto, Source Serif 4, or monospace
families when aligned numeric axes are the requirement.
Anatomy of the fallback chain¶
Plain text in matplotlib resolves glyphs from font.family, so the chain must
name actual bundled families, not just the generic sans-serif alias. The
order starts with the body voice (Roboto, then Inter), moves through
Korean and CJK coverage (Paperlogy, Noto Sans CJK KR, Pretendard), then
lands on math and symbol faces. In the pinned resolver map, digits and most
operators resolve in Roboto, → first appears in Inter, and 한 first appears
in Paperlogy; nothing falls through to DejaVu.
font.sans-serif is held identical to font.family — bundled families plus
the generic terminator, with no machine-dependent OS fonts (Lato, Arial,
Malgun Gothic, …). Anything past the bundle intentionally falls to
matplotlib’s default rather than a font that happens to be installed, so a
figure renders the same on every machine.
Math segments obey the same discipline. The custom mathtext fontset is matched
to the body: rm/it/bf/sf are Roboto, tt is JetBrains Mono, and cal
is the matplotlib-bundled STIXGeneral. Greek and operators absent from those
fall to stixsans (also matplotlib-bundled), never DejaVu, and
mathtext.default: regular makes a bare $R^2$ render in the same upright
body face as the labels around it. Under a -kr preset the body face is
Paperlogy, so the Latin and digits in a math span match the Korean labels while
symbols still resolve through STIX.