# 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`) |