diff --git a/.changeset/unify-and-name-chart-colors.md b/.changeset/unify-and-name-chart-colors.md
new file mode 100644
index 0000000000..9db9e02240
--- /dev/null
+++ b/.changeset/unify-and-name-chart-colors.md
@@ -0,0 +1,20 @@
+---
+'@hyperdx/app': patch
+---
+
+refactor(theme): unify chart palette across HyperDX and ClickStack and address categorical slots by name
+
+The categorical and semantic chart palettes are now identical across
+both themes (defined once in `_chart-tokens.scss`, included by both),
+and categorical slots are addressed by hue name (`--color-chart-blue`,
+`--color-chart-orange`, …) instead of by index (`--color-chart-1`..`-10`).
+The numbered vars are removed.
+
+Brand impact: HyperDX charts no longer lead with brand green
+(`#00c28a`). They now lead with Observable blue (`#437eef`), matching
+ClickStack. Brand identity stays visible via Mantine accent (`green`
+on HyperDX), Click UI globals, sidebar gradient, and other UI chrome.
+
+Multi-series ordering moves from CSS to JS via `CATEGORICAL_ORDER` in
+`packages/app/src/utils.ts`, so reordering default series colors no
+longer requires SCSS edits.
diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md
index 72438f54c1..836d9e2c8f 100644
--- a/agent_docs/data_viz_colors.md
+++ b/agent_docs/data_viz_colors.md
@@ -9,46 +9,107 @@
There are **three** color systems for data viz, with three different
consumption patterns:
-| System | Use for | Source of truth | How to consume |
-| ------------------------------ | ---------------------------------------- | ---------------------------------------- | --------------------------------------------- |
-| **Categorical 1–10** | Multi-series line/bar/area/pie charts | CSS vars `--color-chart-1`..`-10` | `getColorProps(index, label)` in `utils.ts` |
-| **Semantic (success/warn/err)**| Status indicators, log levels, deltas | CSS vars `--color-chart-{success,...}` | `getChartColorSuccess/Warning/Error()` |
-| **Heatmap continuous** | `DBHeatmapChart` density gradients | `darkPalette`/`lightPalette` arrays | Imported directly from `DBHeatmapChart.tsx` |
+| System | Use for | Source of truth | How to consume |
+| ------------------------------ | ---------------------------------------- | ------------------------------------------ | --------------------------------------------- |
+| **Categorical (10 named hues)**| Multi-series line/bar/area/pie charts | CSS vars `--color-chart-{blue,orange,...}` | `getColorProps(index, label)` in `utils.ts` |
+| **Semantic (success/warn/err)**| Status indicators, log levels, deltas | CSS vars `--color-chart-{success,...}` | `getChartColorSuccess/Warning/Error()` |
+| **Heatmap continuous** | `DBHeatmapChart` density gradients | `darkPalette`/`lightPalette` arrays | Imported directly from `DBHeatmapChart.tsx` |
+
+The categorical and semantic palettes are **identical across the HyperDX
+and ClickStack themes** — they're defined once in
+`packages/app/src/theme/themes/_chart-tokens.scss` and `@include`d by
+both themes. Theme branding still differentiates UI chrome (Mantine
+accent, Click UI globals); chart colors do not.
**Hard rules**:
-- **Never** pass a hex color to a chart series. Always go through one of the
- helpers above so theme switching works.
+- **Never** pass a hex color to a chart series. Always go through one of
+ the helpers above so dark/light scheme switching works.
- **Never** map log levels to raw Mantine colors (`red.5`, `yellow.6`).
- Use `logLevelColor()` / `getColorProps()` — they pick the theme-correct
+ Use `logLevelColor()` / `getColorProps()` — they pick the correct
semantic chart color.
- The categorical palette and the heatmap palette are **different things**.
- Don't reuse `--color-chart-N` for heatmap density; don't reuse the heatmap
- arrays for series colors.
+ Don't reuse `--color-chart-{name}` vars for heatmap density; don't reuse
+ the heatmap arrays for series colors.
+
+**Two ways to address the categorical palette**:
+
+- **By name** (identity): `var(--color-chart-cyan)` always means cyan,
+ regardless of where cyan sits in the categorical assignment order. Use
+ this when a specific element should always be the same hue.
+- **By position** (ordering): `getColorProps(index, label)` returns the
+ `index`-th color from `CATEGORICAL_ORDER` in `utils.ts`. Use this for
+ multi-series charts where each series just needs a distinct slot.
+
+The two layers are decoupled: changing `CATEGORICAL_ORDER` to re-prioritize
+which hue is "slot 0" doesn't move any of the named vars around.
## Where the colors live
-### Categorical series palette (`--color-chart-1` through `--color-chart-10`)
+### Categorical series palette (named slots)
+
+Defined once in **`packages/app/src/theme/themes/_chart-tokens.scss`**
+and consumed by both themes via `@include chart-tokens.chart-tokens`
+inside their dark and light scheme blocks:
+
+| CSS var | Hex |
+| --------------------------- | --------- |
+| `--color-chart-blue` | `#437eef` |
+| `--color-chart-orange` | `#efb118` |
+| `--color-chart-red` | `#ff725c` |
+| `--color-chart-cyan` | `#6cc5b0` |
+| `--color-chart-green` | `#3ca951` |
+| `--color-chart-pink` | `#ff8ab7` |
+| `--color-chart-purple` | `#a463f2` |
+| `--color-chart-light-blue` | `#97bbf5` |
+| `--color-chart-brown` | `#9c6b4e` |
+| `--color-chart-gray` | `#9498a0` |
+
+Source: [Observable 10 categorical palette](https://observablehq.com/@d3/color-schemes).
+Designed to be distinguishable on both dark and light backgrounds and
+for color-vision-deficient viewers.
+
+#### Ordering for multi-series charts
+
+The categorical assignment order — which named slot is "slot 0", "slot 1",
+etc. for multi-series charts — lives entirely in JS, in
+`packages/app/src/utils.ts`:
+
+```ts
+const CATEGORICAL_ORDER = [
+ 'blue', 'orange', 'red', 'cyan', 'green',
+ 'pink', 'purple', 'lightBlue', 'brown', 'gray',
+] as const;
+```
+
+`getColorProps(index, label)` walks this array. To re-prioritize the
+default ordering for all charts, edit `CATEGORICAL_ORDER` — no SCSS edit
+required. The named vars stay where they are.
+
+#### Why one palette across both themes
+
+Originally HyperDX led with brand green (`#00c28a`) and ClickStack led
+with Observable blue. That coupled "brand identity" with "chart slot 1",
+which in practice caused two problems:
-| Theme | File | Index 1 (primary) |
-| ----------- | ----------------------------------------------------------------------- | ----------------- |
-| HyperDX | `packages/app/src/theme/themes/hyperdx/_tokens.scss` (lines ~95–117) | `#00c28a` brand green |
-| ClickStack | `packages/app/src/theme/themes/clickstack/_tokens.scss` (lines ~175–201)| `#437eef` Observable blue |
+- The HyperDX brand-green also doubled as `--color-chart-success`, so
+ success pills and primary chart series shared a hue.
+- Per-theme palette ordering required a runtime `detectActiveTheme()`
+ branch in the JS fallback path and an SSR/hydration mismatch caveat.
-The same ten slots in both themes use the same hue families (blue, orange,
-red, cyan, green, pink, purple, light blue, brown, gray) — only **slot 1**
-differs:
+Unifying on one palette removes both concerns. Brand identity stays in
+the UI chrome (Mantine accent yellow vs green, sidebar gradient, etc.);
+chart-color identity is now a stable, theme-agnostic contract.
-- **HyperDX** leads with brand green, then Observable colors.
-- **ClickStack** leads with Observable blue (Click UI accent yellow doesn't
- pass contrast on a typical chart background, so we don't use it as a series
- color — see "Per-theme considerations" below).
+#### Why scheme blocks still redeclare
-The vars are defined identically inside the dark and light selectors. That
-duplication is intentional and called out in `_tokens.scss`: CSS specificity
-requires it because the parent selectors `[data-mantine-color-scheme='dark']`
-and `[data-mantine-color-scheme='light']` would otherwise drop the vars on
-scheme switch.
+Even though the values are identical between dark and light, both
+scheme blocks `@include chart-tokens.chart-tokens` rather than declaring
+the vars at a parent selector. The reason is CSS specificity: the rest
+of the design tokens are scoped to
+`[data-mantine-color-scheme='dark|light']` and would otherwise win the
+cascade and reset the chart vars to `unset`. The shared partial keeps
+the values in lockstep without sacrificing specificity.
### Semantic chart colors
@@ -61,26 +122,40 @@ scheme switch.
--color-chart-error-highlight
```
-Defined in both `_tokens.scss` files. **HyperDX success uses brand green
-(`#00c28a`)**; **ClickStack success uses Observable green (`#3ca951`)** so it
-doesn't collide with the yellow brand accent. Warning and error are the same
-across themes (orange `#efb118`, red `#ff725c`).
+Also defined in `_chart-tokens.scss`. Identical across themes:
-### JavaScript fallback (`packages/app/src/utils.ts`)
+| Var | Hex |
+| ---------------------------------- | --------- |
+| `--color-chart-success` | `#3ca951` |
+| `--color-chart-warning` | `#efb118` |
+| `--color-chart-error` | `#ff725c` |
+| `--color-chart-success-highlight` | `#80d9b3` |
+| `--color-chart-warning-highlight` | `#f5c94d` |
+| `--color-chart-error-highlight` | `#ffa090` |
-The CSS vars are the source of truth at runtime, but two palette objects in
-`utils.ts` are the SSR fallback **and** the storybook reference:
+Note that `--color-chart-success` (`#3ca951`) is **not** the same var as
+`--color-chart-green` (`#3ca951`) — they happen to coincide today but
+they're two different vars with different intents. Treat them as
+independent contracts: success can change to a less-saturated green
+without touching the categorical palette, and vice versa.
+
+### JavaScript palette (`packages/app/src/utils.ts`)
+
+The CSS vars are the source of truth at runtime; `utils.ts` mirrors them
+in three pieces:
```text
-CHART_PALETTE # HyperDX (green-first), lines ~356-374
-CLICKSTACK_CHART_PALETTE # ClickStack (blue-first), lines ~376-393
-COLORS # Exported, ordered, HyperDX-default array, lines ~398-409
+CHART_PALETTE # name -> hex map, mirrors `_chart-tokens.scss`
+CATEGORICAL_ORDER # ordered list of palette keys for slot-N assignment
+COLORS # exported hex array, derived from CATEGORICAL_ORDER
```
-`COLORS[0]` corresponds to `--color-chart-1`, `COLORS[1]` to `--color-chart-2`,
-and so on. **Keep them in sync.** If you change a hex in one place, change it
-in all three (HyperDX SCSS, ClickStack SCSS, and `CHART_PALETTE` /
-`CLICKSTACK_CHART_PALETTE` / `COLORS`).
+`COLORS[i]` always equals `CHART_PALETTE[CATEGORICAL_ORDER[i]]`. It's
+exported as the SSR fallback for `getColorFromCSSVariable(index)` and a
+convenience for callers that want a positional hex without going through
+the helpers. **Keep them in sync.** A hex change must update both
+`_chart-tokens.scss` and `CHART_PALETTE`. Re-ordering the categorical
+assignment is a `CATEGORICAL_ORDER`-only edit.
### Reader functions (`packages/app/src/utils.ts`)
@@ -99,16 +174,14 @@ These are the only functions React code should call:
Internals worth knowing:
-- `getColorFromCSSVariable(index)` reads `--color-chart-{index+1}` from
- `documentElement` via `getComputedStyle`. On SSR or if the var is missing,
- it falls back to `COLORS[index % COLORS.length]`.
-- `getSemanticChartColor(varName, hyperdxFallback, clickstackFallback)` does
- the same for the semantic vars and uses `detectActiveTheme()` (checks for
- the `theme-clickstack` class on ``) to pick the correct fallback when
- CSS isn't available.
-- During SSR, semantic readers return the **HyperDX** default — the
- hydration mismatch window is tiny because charts render after data fetch
- on the client.
+- `getColorFromCSSVariable(index)` looks up the categorical slot name via
+ `CATEGORICAL_ORDER[index % CATEGORICAL_ORDER.length]`, then reads the
+ matching `--color-chart-{kebab-case name}` var from `documentElement`
+ via `getComputedStyle`. On SSR or if the var is missing, it falls back
+ to `CHART_PALETTE[name]`.
+- `getSemanticChartColor(varName, fallback)` does the same for the
+ semantic vars. Single-argument fallback — both themes resolve to the
+ same hex now, so the SSR fallback always matches the live value.
### Heatmap palette (component-local)
@@ -119,7 +192,7 @@ Internals worth knowing:
- Selected at the call site by `useMantineColorScheme()` and
`colorScheme === 'light' ? lightPalette : darkPalette`.
-These are **scheme-aware, not brand-aware**: HyperDX dark and ClickStack
+These are **scheme-aware, not theme-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.
@@ -129,59 +202,35 @@ 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:
+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 `--color-chart-1` (it's a comparison reference, not a series).
-- Trace waterfall span tints in `DBTraceWaterfallChart.tsx` — derived from
- span attributes, not from this palette.
+ reference bar in `DBDeltaChart`. Keep using this var; don't replace
+ it with `--color-chart-blue` (it's a comparison reference, not a
+ categorical 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:
-
-```1:22:packages/app/src/theme/ChartColors.stories.tsx
-import React from 'react';
-
-import {
- COLORS,
- getChartColorError,
- getChartColorSuccess,
- getChartColorWarning,
-} from '@/utils';
-
-// Labels for chart colors - brand green first, then Observable palette
-const COLOR_LABELS = [
- 'Green (Brand)',
- 'Blue',
- 'Orange',
- 'Red',
- 'Cyan',
- 'Pink',
- 'Purple',
- 'Light Blue',
- 'Brown',
- 'Gray',
-];
-```
-
-It renders `AllChartColors`, `BarChartPreview`, `LineChartPreview`,
-`SemanticColorsPreview`, and `AccessibilityCheck`. Run storybook in the
-`app` package to inspect both schemes side by side.
+storybook story at `packages/app/src/theme/ChartColors.stories.tsx`. It
+renders `AllChartColors`, `BarChartPreview`, `LineChartPreview`,
+`SemanticColorsPreview`, and `AccessibilityCheck`. Both schemes look
+identical across the two brand themes (since chart vars are shared);
+toggle dark/light to verify legibility.
## How to consume (recipes)
### Multi-series time-series chart
-`ChartUtils.tsx → setLineColors` already wires the categorical palette per
-series via `getColorProps(index, level)` in
+`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.
+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:
@@ -195,9 +244,10 @@ const series = data.map((s, i) => ({
}));
```
-`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.
+`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.
### Status pill / delta indicator
@@ -208,19 +258,20 @@ 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).
+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:
+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.).
+The var route reacts instantly to scheme switches without re-running
+React. Use the function form only when you need a string at compute
+time (canvas/WebGL, library config objects, etc.).
### Heatmap
@@ -237,10 +288,10 @@ 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:
+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);
@@ -250,76 +301,78 @@ const slices = sorted.map((d, i) => ({
}));
```
-`ChartUtils.tsx → buildPieChartData` already does this — the comment "Sort
-in descending order so the largest slice is always first and gets the
-first color in the palette" is at line ~444.
+`ChartUtils.tsx → buildPieChartData` already does this — the comment
+"Sort in descending order so the largest slice is always first and gets
+the first color in the palette" is at line ~444.
## Per-theme considerations
-### HyperDX (green-first)
-
-- Slot 1 is brand green (`#00c28a`) so single-series charts feel
- on-brand without any extra config.
-- Semantic success **also** uses brand green, so success indicators and
- primary series share a hue. This is intentional but worth knowing:
- if a chart juxtaposes a "success" pill with a green series, that's
- expected, not a bug.
-
-### ClickStack (blue-first)
-
-- 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.
-- Slot 1 falls back to Observable blue (`#437eef`).
-- Semantic success is **Observable green** (`#3ca951`), distinct from
- the yellow brand accent. Don't try to "brand" success with yellow.
+The categorical and semantic chart palettes are **theme-agnostic** —
+both HyperDX and ClickStack resolve every `--color-chart-*` var to the
+same hex. Theme branding lives elsewhere:
-### Both themes
+- **Mantine accent**: `green` for HyperDX, `yellow` for ClickStack.
+ Affects `