# Data Visualization Colors
> Single source of truth for chart and visualization colors in ClickStack. Read
> this before adding, changing, or hard-coding a color in any chart, sparkline,
> heatmap, legend, status pill, or other data display.
## TL;DR
There are **three** color systems for data viz, with three different consumption
patterns:
| System | Use for | Source of truth | How to consume |
| ------------------------------------ | ------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Categorical (10 hues)** | Multi-series line/bar/area/pie charts | `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` (CSS vars mirror it) | `getColorProps(index, label)` (positional) / `getColorFromCSSToken('chart-{hue}')` (by name) |
| **Semantic (success/warn/err/info)** | Status indicators, log levels, deltas | CSS vars `--color-chart-{success,warning,error,info}` | `getChartColor{Success,Warning,Error,Info}()` |
| **Heatmap continuous** | `DBHeatmapChart` density gradients | `darkPalette`/`lightPalette` arrays | Imported directly from `DBHeatmapChart.tsx` |
**Hard rules**:
- **Never** pass a hex color to a chart series. Always go through one of the
helpers above so theme switching works.
- **Never** map log levels to raw Mantine colors (`red.5`, `yellow.6`). Use
`logLevelColor()` / `getColorProps()` — they pick the theme-correct semantic
chart color.
- The categorical palette and the heatmap palette are **different things**.
Don't reuse `--color-chart-{hue}` for heatmap density; don't reuse the heatmap
arrays for series colors.
## Where the colors live
### Categorical palette (`--color-chart-{hue}`)
The categorical palette is **unified across themes** — based on Observable 10
([d3 schemeObservable10](https://observablehq.com/@d3/color-schemes)), with
`chart-blue` swapped to `#437eef` so it matches the brand link color
(`--click-global-color-text-link-default` for ClickStack, same hue for HyperDX
series). All other hues are straight from Observable 10. Identical on HyperDX
and ClickStack:
| Token | Hex |
| ------------------ | --------- |
| `chart-blue` | `#437eef` |
| `chart-orange` | `#efb118` |
| `chart-red` | `#ff725c` |
| `chart-cyan` | `#6cc5b0` |
| `chart-green` | `#3ca951` |
| `chart-pink` | `#ff8ab7` |
| `chart-purple` | `#a463f2` |
| `chart-light-blue` | `#97bbf5` |
| `chart-brown` | `#9c6b4e` |
| `chart-gray` | `#9498a0` |
**Source of truth for categorical hues lives in JS.** The matching
`--color-chart-{hue}` CSS vars, included from
`packages/app/src/theme/_tokens.scss`, are a stylesheet-author
affordance only — `getColorFromCSSVariable` and `getColorFromCSSToken` skip
`getComputedStyle` for categorical tokens and return values straight from
`CATEGORICAL_HEX_BY_TOKEN` in `utils.ts`. A DOM round-trip per series buys
nothing.
The CSS vars exist for:
- SCSS modules and inline `style={{ background: 'var(--color-chart-blue)' }}`
consumers (no JS import needed).
- Devtools inspection while debugging chart styling.
- Forward-compat: if a future brand wants to override hues, switch
`getColorFromCSSVariable`/`getColorFromCSSToken` back to reading the var and
add per-brand entries to `CATEGORICAL_HEX_BY_TOKEN`.
The categorical hues and semantic chart colors live in a single shared partial,
`packages/app/src/theme/_chart-categorical-tokens.scss` (`chart-categorical-tokens`
and `chart-semantic-tokens` mixins). `theme/_tokens.scss` `@use`s it and
`@include`s both mixins inside its `chart-tokens` mixin, which is then
`@include`'d inside both `[data-mantine-color-scheme]` selectors. Sass inlines
the bodies at each call site, so the emitted CSS has the same per-scheme
specificity as a hand-duplicated block would — but the source lives in **one
place**. **If you change a categorical hex in the shared partial, change it in
`CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` too. If you change a semantic hex,
update `SEMANTIC_CHART_PALETTE` in `utils.ts` too — the SCSS and JS sources are
intentionally mirrored.**
Chart series colors are not the brand accent. `success` and `info` reuse
categorical `chart-green` and `chart-blue`. The yellow ClickStack accent stays
in non-chart chrome (Mantine accent, sidebar, Click UI globals).
### Semantic chart colors (`--color-chart-{success|warning|error|info}`)
```text
--color-chart-success # success / OK / ingested fills
--color-chart-warning # warnings, throttling, slowdowns
--color-chart-error # failures, errors, alerts firing
--color-chart-info # info-level logs, neutral "primary" series
--color-chart-success-highlight # hover/selected variants (lighter shades)
--color-chart-warning-highlight
--color-chart-error-highlight
```
Defined in the shared `chart-semantic-tokens` mixin. `success` reuses
categorical `chart-green` (`#3ca951`); `info` reuses categorical `chart-blue`
(`#437eef`). Warning and error use the same Observable hexes.
Unlike the categorical hues, **the semantic CSS vars are read at runtime** via
`getComputedStyle` (see `getSemanticChartColor` in `utils.ts`). That keeps
inline `var(--color-chart-warning)` consumers like `LogLevel.tsx` in sync with
the stylesheet, and it lets JS callers like `getChartColorSuccess()` return the
same hex.
### Single source of truth in TS (`packages/app/src/utils.ts`)
```text
CATEGORICAL_HEX_BY_TOKEN # { 'chart-blue': '#437eef', ... } — file-private,
# authoritative for categorical hues
SEMANTIC_CHART_PALETTE # { success, warning, error, info, …highlight } — file-private,
# SSR/fallback for the semantic CSS vars
COLORS # ordered hex array, derived from CATEGORICAL_PALETTE_TOKENS
```
`COLORS[i]` equals `CATEGORICAL_HEX_BY_TOKEN[CATEGORICAL_PALETTE_TOKENS[i]]`.
`COLORS` is what `getColorFromCSSVariable(i)` returns — on both server and
client, since categorical hues no longer round-trip through the CSS var.
The hue-named `CHART_PALETTE_TOKENS`, `CATEGORICAL_PALETTE_TOKENS`, and
`SEMANTIC_PALETTE_TOKENS` constants live in `packages/common-utils/src/types.ts`
so the Zod schema can reference them (shared with the API).
### Reader functions (`packages/app/src/utils.ts`)
These are the only functions React code should call:
| Function | Returns |
| -------------------------------- | ----------------------------------------------------------- |
| `getColorProps(index, level)` | Categorical color by index, with log-level override applied |
| `semanticKeyedColor(key, index)` | Same, but driven by `key` (e.g. series name) |
| `getColorFromCSSToken(token)` | Resolves any `ChartPaletteToken` (categorical or semantic) |
| `getChartColorSuccess()` | `var(--color-chart-success)` resolved to a hex string |
| `getChartColorWarning()` | `var(--color-chart-warning)` resolved |
| `getChartColorError()` | `var(--color-chart-error)` resolved |
| `getChartColorInfo()` | `var(--color-chart-info)` resolved (brand-primary for info) |
| `getChartColor*Highlight()` | Hover/selected variants |
| `logLevelColor(key)` | Maps `'error' \| 'warn' \| 'info'` → semantic color |
| `getLogLevelColorOrder()` | Stable ordering for log-level series |
Internals worth knowing:
- `getColorFromCSSVariable(index)` returns `COLORS[index % 10]` directly — no
DOM read. Categorical hues are unified across themes, so the JS palette is
authoritative.
- `getColorFromCSSToken(token)` is split:
- Categorical tokens (`chart-blue`, etc.) come straight from
`CATEGORICAL_HEX_BY_TOKEN` — same shortcut, no `getComputedStyle`.
- Semantic tokens (`chart-success`, `-warning`, `-error`) read
`--color-{token}` from `documentElement` via `getComputedStyle` and fall
back to `SEMANTIC_CHART_PALETTE` when running server-side or when the DOM
read fails.
- `getSemanticChartColor(varName, key)` is the shared helper that backs the
`getChartColor{Success,Warning,Error,Info}()` readers. SSR and DOM-read
failures use `SEMANTIC_CHART_PALETTE`. Charts usually render after data
fetch on the client, so that fallback rarely reaches the DOM.
### Legacy `chart-1` … `chart-10` tokens
The number-tile color picker (#2265) initially shipped with numeric tokens
(`chart-1` … `chart-10`). Renamed here to hue-named tokens so stored configs and
the upcoming external API surface are self-documenting.
Existing stored configs keep working. The mapping:
```text
chart-1 -> chart-green (was HyperDX brand green at slot 1)
chart-2 -> chart-blue (was HyperDX slot 2)
chart-3 -> chart-orange
...
chart-10 -> chart-gray
```
It preserves the HyperDX slot ordering from #2265, so HyperDX users see no
visual change.
> **⚠️ ClickStack legacy color caveat.** Pre-rename ClickStack used a different
> slot ordering than HyperDX (`--color-chart-1` was brand blue `#437eef`, not
> brand green; `--color-chart-2` was orange, not blue; etc.). Because the
> migration map preserves HyperDX slot ordering, any ClickStack dashboard saved
> via #2265 will visually shift: stored `chart-1` flips from brand blue to
> Observable green, `chart-2` flips from orange to blue, and so on. We chose
> this trade-off deliberately over branching the legacy map by active theme:
> `LEGACY_CHART_PALETTE_TOKEN_MAP` lives in `common-utils` (shared with the
> API), and migration is one-shot persisted on next save — theme-branching would
> couple common-utils to browser DOM state and still produce wrong results for
> users whose active theme changed since the original pick. Affected users can
> manually re-pick the desired hue via the (now hue-labeled) color picker. Use
> `chart-info` semantic if you need the brand-primary appearance.
**`ChartPaletteTokenSchema` itself stays strict** (a plain `z.enum`). Wrapping
it in `z.preprocess` would force the schema's `z.input` type to `unknown`, which
poisons `validateRequest`'s `req.body` inference in the API package all the way
up to `Dashboard.tiles[i].config.color`. Strict input/output equality is more
important than a one-line runtime migration buried in the schema.
Migration happens at two complementary layers instead:
1. **Fetch-time _and_ write-time** — `normalizeDashboardTileColors` in
`packages/app/src/dashboard.ts` walks every tile and rewrites any legacy
`config.color` to its hue-named equivalent via `resolveChartPaletteToken`. It
runs on every read (`useDashboards` / `fetchLocalDashboards`) and on every
write (`useUpdateDashboard` / `useCreateDashboard`). The write-time pass is
what lets JSON imports (`DBDashboardImportPage`), presets, and
MCP-constructed payloads pass the strict server-side validator, and it
converges the DB-side data on next save instead of leaving legacy tokens in
storage forever.
2. **Render-time** — `DBNumberChart` and `ColorSwatchInput` also call
`resolveChartPaletteToken` as belt-and-suspenders for tiles constructed in
memory between fetch and save (e.g. `ChartEditor` form state, unit-test
fixtures, hand-rolled `Tile` literals).
`LEGACY_CHART_PALETTE_TOKEN_MAP` and `resolveChartPaletteToken` live in
`packages/common-utils/src/types.ts` next to the enum.
### Heatmap palette (component-local)
`packages/app/src/components/DBHeatmapChart.tsx`:
- `darkPalette` (lines ~145–155): 7 stops, indigo → amber.
- `lightPalette` (lines ~156–164): 7 stops, medium blue → deep orange.
- Selected at the call site by `useMantineColorScheme()` and
`colorScheme === 'light' ? lightPalette : darkPalette`.
These are **scheme-aware, not brand-aware**: HyperDX dark and ClickStack dark
share the same heatmap gradient, same for light. Red is intentionally omitted
from the high end so it can be reserved for error overlays.
`DBHeatmapChart.tsx` re-exports `darkPalette` and `lightPalette`.
`DBSearchHeatmapChart.tsx` imports them via that re-export — **do not**
duplicate the arrays in another component.
### Trace and delta-specific colors
A few component-local accents that are **not** part of the categorical or
semantic palettes:
- `ALL_SPANS_COLOR = 'var(--mantine-color-blue-6)'` in
`packages/app/src/components/deltaChartUtils.ts` — the "all spans" reference
bar in `DBDeltaChart`. Keep using this var; don't replace it with a
categorical token (it's a comparison reference, not a series).
- Trace waterfall span tints in `DBTraceWaterfallChart.tsx` — derived from span
attributes, not from this palette.
## Storybook reference
The visual reference for the categorical and semantic palettes is the storybook
story at `packages/app/src/theme/ChartColors.stories.tsx`. It renders
`AllChartColors`, `BarChartPreview`, `LineChartPreview`,
`SemanticColorsPreview`, and `AccessibilityCheck`. Run storybook in the `app`
package to inspect both schemes side by side.
The number-tile color picker (`ColorSwatchInput.stories.tsx`) renders the same
tokens through the user-facing picker UI.
## How to consume (recipes)
### Multi-series time-series chart
`ChartUtils.tsx → setLineColors` already wires the categorical palette per
series via `getColorProps(index, level)` in `packages/app/src/ChartUtils.tsx`.
**You should not have to think about chart colors when adding a new chart that
goes through `seriesToTimeSeries` / `setLineColors`** — they handle it.
If you're rendering a custom chart outside that pipeline:
```tsx
import { getColorProps } from '@/utils';
const series = data.map((s, i) => ({
name: s.label,
color: getColorProps(i, s.label),
data: s.points,
}));
```
`level` (the second arg) is used to override with semantic colors when the label
looks like a log level (`'error'`, `'warn'`, `'info'`, etc.). Pass `s.label` if
it might encode a log level, otherwise pass an empty string.
### Identity color ("this thing is always blue")
For UI surfaces that should always render a specific hue regardless of
multi-series ordering:
```tsx
import { getColorFromCSSToken } from '@/utils';
Always blue;
```
Or directly in CSS:
```tsx
```
### Status pill / delta indicator
```tsx
import { getChartColorError, getChartColorSuccess } from '@/utils';
```
These functions return resolved hex strings, so they can be used in inline
styles or passed to libraries that don't understand CSS vars (e.g. `uPlot`'s
canvas fills).
If you're styling a DOM element with regular CSS, prefer the var directly:
```tsx
```
The var route reacts instantly to theme switches without re-running React. Use
the function form only when you need a string at compute time (canvas/WebGL,
library config objects, etc.).
### User-customizable chart color (number tile etc.)
`ColorSwatchInput` stores the choice as a `ChartPaletteToken`. Resolve to a hex
at paint time via `getColorFromCSSToken(token)`. Never store hex strings in
chart configs — tokens reflow correctly across themes and color modes.
### Heatmap
```tsx
import { useMantineColorScheme } from '@mantine/core';
import { darkPalette, lightPalette } from '@/components/DBHeatmapChart';
const { colorScheme } = useMantineColorScheme();
const palette = colorScheme === 'light' ? lightPalette : darkPalette;
```
That's the only correct way to consume the heatmap gradient. Never reconstruct
it.
### Pie / donut where slice order matters
The categorical palette is **ordered for distinguishability** — adjacent slots
are designed to contrast. If you're drawing a pie chart where the largest slice
should always be the most prominent color, sort your data first and let the
index map to the palette naturally:
```tsx
const sorted = data.toSorted((a, b) => b.value - a.value);
const slices = sorted.map((d, i) => ({
...d,
color: getColorProps(i, d.label),
}));
```
`ChartUtils.tsx → buildPieChartData` already does this.
## Color mode
- `--color-chart-success` uses categorical `chart-green` (`#3ca951`).
- `--color-chart-info` uses categorical `chart-blue` (`#437eef`, same as
`--click-global-color-text-link-default`), so info-level logs and
`getChartColorInfo()` render that blue.
- The brand accent is **yellow** (`--palette-brand-300: #faff69`). It is **not**
in the chart palette and should not be added — yellow on a light background
fails contrast, and yellow as a series color reads as "warning" in most
contexts.
- The dark/light scheme split is handled by CSS (the same vars get redefined
inside each scheme selector). React code does not need to branch on scheme
except for the heatmap palette.
- The chart vars are intentionally included in both the dark and light blocks.
Change the shared mixin, not one selector.
## Adding new entries
### A new categorical hue (11th token)
Don't, unless you have a real need. Ten distinguishable hues is the upper bound
for readable categorical legends — beyond that, viewers can't tell slices apart,
and color-blind viewers definitely can't. Solutions in descending order of
preference:
1. **Group small categories** into "Other" before charting.
2. **Reuse slots** with patterns/strokes/labels for disambiguation.
3. If you really must extend: add `--color-chart-{newhue}` to the shared
`_chart-categorical-tokens.scss` partial — one edit covers both schemes via
the existing `@include` chain. Then append
`'chart-{newhue}'` to `CHART_PALETTE_TOKENS` in `common-utils/src/types.ts`,
add the hex to `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts`, and add a label
entry in `ColorSwatchInput.tsx` → `TOKEN_LABELS` and
`ChartColors.stories.tsx` → `COLOR_LABELS`.
### A new semantic color (e.g. `--color-chart-pending`)
1. Pick the hex.
2. Add `--color-chart-pending` and (if needed) `--color-chart-pending-highlight`
to the `@mixin chart-semantic-tokens` block in
`_chart-categorical-tokens.scss`. The mixin is `@include`'d from
`theme/_tokens.scss` inside each scheme selector — one edit covers dark
and light.
3. Add `pending` (and optionally `pendingHighlight`) to `SEMANTIC_CHART_PALETTE`
in `utils.ts`.
4. Append `'chart-pending'` to `CHART_PALETTE_TOKENS` (and
`SEMANTIC_PALETTE_TOKENS` slice will pick it up automatically) in
`common-utils/src/types.ts`.
5. Add a `getChartColorPending()` reader in `utils.ts` that calls
`getSemanticChartColor('--color-chart-pending', 'pending')`.
6. Update `ChartColors.stories.tsx` `SEMANTIC_CHART_COLORS` so the new color
shows up in the design-tokens story.
7. Add a `'chart-pending': 'Pending'` entry to `TOKEN_LABELS` in
`ColorSwatchInput.tsx`.
8. If it should override based on a label / status string, extend
`getLevelColor` / `logLevelColor` accordingly.
### A new heatmap palette
The current arrays were tuned to (a) be visible on the chart's dark/light
background, (b) avoid red at the high end so error overlays stay readable, and
(c) follow a perceptually monotonic luminance ramp. Don't add a new palette
without those three properties verified — both "physically dim" and
"perceptually noisy" gradients hurt readability.
If you do need to add one (e.g. for a different chart type), keep it
component-local like the existing ones, and re-use the same scheme-pick pattern
(`useMantineColorScheme` + ternary).
## Anti-patterns
```tsx
// hex string in a chart
// CSS color keyword
// raw Mantine palette for a status
// palette object is module-private
```
Why each is wrong:
- **Hex / keyword**: bypasses theme switching. Dark/light won't react, and any
future per-theme tweak won't propagate.
- **Raw Mantine**: bypasses semantic mapping. `green.5` is not the same as
`--color-chart-success` once we tweak the brand palette.
- **Importing palette objects**: `CATEGORICAL_HEX_BY_TOKEN` and
`SEMANTIC_CHART_PALETTE` are module-private. The exported surface is the
reader functions, the token enums, and `COLORS` (the SSR fallback array —
prefer `getColorProps` / `getColorFromCSSToken` over indexing it directly).
## Pre-merge checklist for chart-touching PRs
- [ ] Toggled dark ↔ light — categorical and semantic colors stay legible on
both backgrounds; heatmap gradient flips palettes.
- [ ] Status indicators use `getChartColor*()` / `var(--color-chart-*)`, not raw
Mantine colors.
- [ ] No new hex strings in chart components — all colors flow through
`utils.ts` helpers or CSS vars.
- [ ] If you added or changed a categorical hex, changed it in **both** places
(`_chart-categorical-tokens.scss` shared partial,
`CATEGORICAL_HEX_BY_TOKEN` in `utils.ts`).
- [ ] If you added or changed a semantic hex, changed it in **both** places
(`chart-semantic-tokens` mixin in `_chart-categorical-tokens.scss`,
`SEMANTIC_CHART_PALETTE` in `utils.ts`).
- [ ] Storybook `Design tokens / Chart colors` still renders correctly.
## File reference summary
| What | Where |
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
| Shared chart vars (10 categorical hues + semantics) | `packages/app/src/theme/_chart-categorical-tokens.scss` |
| `chart-tokens` mixin (`@include` chain) | `packages/app/src/theme/_tokens.scss` |
| JS palette objects + reader functions | `packages/app/src/utils.ts` |
| Palette token enum + legacy migration | `packages/common-utils/src/types.ts` |
| Multi-series wiring (`setLineColors` etc.) | `packages/app/src/ChartUtils.tsx` |
| Number-tile color picker | `packages/app/src/components/ColorSwatchInput.tsx` |
| Heatmap palettes | `packages/app/src/components/DBHeatmapChart.tsx` (`darkPalette`, `lightPalette`) |
| Storybook visual reference | `packages/app/src/theme/ChartColors.stories.tsx` |
| Delta "all spans" reference color | `packages/app/src/components/deltaChartUtils.ts` (`ALL_SPANS_COLOR`) |