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.
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). UselogLevelColor()/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.
The categorical palette is unified across themes — based on Observable 10
(d3 schemeObservable10), 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/getColorFromCSSTokenback to reading the var and add per-brand entries toCATEGORICAL_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 @uses it and
@includes 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).
--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.
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).
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)returnsCOLORS[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 fromCATEGORICAL_HEX_BY_TOKEN— same shortcut, nogetComputedStyle. - Semantic tokens (
chart-success,-warning,-error) read--color-{token}fromdocumentElementviagetComputedStyleand fall back toSEMANTIC_CHART_PALETTEwhen running server-side or when the DOM read fails.
- Categorical tokens (
getSemanticChartColor(varName, key)is the shared helper that backs thegetChartColor{Success,Warning,Error,Info}()readers. SSR and DOM-read failures useSEMANTIC_CHART_PALETTE. Charts usually render after data fetch on the client, so that fallback rarely reaches the DOM.
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:
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-1was brand blue#437eef, not brand green;--color-chart-2was orange, not blue; etc.). Because the migration map preserves HyperDX slot ordering, any ClickStack dashboard saved via #2265 will visually shift: storedchart-1flips from brand blue to Observable green,chart-2flips 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_MAPlives incommon-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. Usechart-infosemantic 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:
- Fetch-time and write-time —
normalizeDashboardTileColorsinpackages/app/src/dashboard.tswalks every tile and rewrites any legacyconfig.colorto its hue-named equivalent viaresolveChartPaletteToken. 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. - Render-time —
DBNumberChartandColorSwatchInputalso callresolveChartPaletteTokenas belt-and-suspenders for tiles constructed in memory between fetch and save (e.g.ChartEditorform state, unit-test fixtures, hand-rolledTileliterals).
LEGACY_CHART_PALETTE_TOKEN_MAP and resolveChartPaletteToken live in
packages/common-utils/src/types.ts next to the enum.
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()andcolorScheme === '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.
A few component-local accents that are not part of the categorical or semantic palettes:
ALL_SPANS_COLOR = 'var(--mantine-color-blue-6)'inpackages/app/src/components/deltaChartUtils.ts— the "all spans" reference bar inDBDeltaChart. 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.
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.
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:
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.
For UI surfaces that should always render a specific hue regardless of multi-series ordering:
import { getColorFromCSSToken } from '@/utils';
<Text c={getColorFromCSSToken('chart-blue')}>Always blue</Text>;Or directly in CSS:
<Box style={{ background: 'var(--color-chart-blue)' }} />import { getChartColorError, getChartColorSuccess } from '@/utils';
<Box style={{ background: getChartColorError() }} />
<Box style={{ background: getChartColorSuccess() }} />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:
<Box style={{ background: 'var(--color-chart-success)' }} />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.).
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.
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.
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:
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-chart-successuses categoricalchart-green(#3ca951).--color-chart-infouses categoricalchart-blue(#437eef, same as--click-global-color-text-link-default), so info-level logs andgetChartColorInfo()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.
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:
- Group small categories into "Other" before charting.
- Reuse slots with patterns/strokes/labels for disambiguation.
- If you really must extend: add
--color-chart-{newhue}to the shared_chart-categorical-tokens.scsspartial — one edit covers both schemes via the existing@includechain. Then append'chart-{newhue}'toCHART_PALETTE_TOKENSincommon-utils/src/types.ts, add the hex toCATEGORICAL_HEX_BY_TOKENinutils.ts, and add a label entry inColorSwatchInput.tsx→TOKEN_LABELSandChartColors.stories.tsx→COLOR_LABELS.
- Pick the hex.
- Add
--color-chart-pendingand (if needed)--color-chart-pending-highlightto the@mixin chart-semantic-tokensblock in_chart-categorical-tokens.scss. The mixin is@include'd fromtheme/_tokens.scssinside each scheme selector — one edit covers dark and light. - Add
pending(and optionallypendingHighlight) toSEMANTIC_CHART_PALETTEinutils.ts. - Append
'chart-pending'toCHART_PALETTE_TOKENS(andSEMANTIC_PALETTE_TOKENSslice will pick it up automatically) incommon-utils/src/types.ts. - Add a
getChartColorPending()reader inutils.tsthat callsgetSemanticChartColor('--color-chart-pending', 'pending'). - Update
ChartColors.stories.tsxSEMANTIC_CHART_COLORSso the new color shows up in the design-tokens story. - Add a
'chart-pending': 'Pending'entry toTOKEN_LABELSinColorSwatchInput.tsx. - If it should override based on a label / status string, extend
getLevelColor/logLevelColoraccordingly.
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).
<Bar fill="#4269d0" /> // hex string in a chart
<Line stroke="red" /> // CSS color keyword
<Pill bg="green.5" /> // raw Mantine palette for a status
<Bar fill={CATEGORICAL_HEX_BY_TOKEN['chart-blue']} /> // palette object is module-privateWhy 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.5is not the same as--color-chart-successonce we tweak the brand palette. - Importing palette objects:
CATEGORICAL_HEX_BY_TOKENandSEMANTIC_CHART_PALETTEare module-private. The exported surface is the reader functions, the token enums, andCOLORS(the SSR fallback array — prefergetColorProps/getColorFromCSSTokenover indexing it directly).
- 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.tshelpers or CSS vars. - If you added or changed a categorical hex, changed it in both places
(
_chart-categorical-tokens.scssshared partial,CATEGORICAL_HEX_BY_TOKENinutils.ts). - If you added or changed a semantic hex, changed it in both places
(
chart-semantic-tokensmixin in_chart-categorical-tokens.scss,SEMANTIC_CHART_PALETTEinutils.ts). - Storybook
Design tokens / Chart colorsstill renders correctly.
| 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) |