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 `