Skip to content

Latest commit

 

History

History
476 lines (378 loc) · 23 KB

data_viz_colors.md

File metadata and controls

476 lines (378 loc) · 23 KB

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), 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 @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).

Semantic chart colors (--color-chart-{success|warning|error|info})

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

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:

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:

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:

import { getColorFromCSSToken } from '@/utils';

<Text c={getColorFromCSSToken('chart-blue')}>Always blue</Text>;

Or directly in CSS:

<Box style={{ background: 'var(--color-chart-blue)' }} />

Status pill / delta indicator

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.).

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

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:

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

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