Skip to content
20 changes: 20 additions & 0 deletions .changeset/unify-and-name-chart-colors.md
Original file line number Diff line number Diff line change
@@ -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.
399 changes: 227 additions & 172 deletions agent_docs/data_viz_colors.md

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ function addResponseToFormattedData({
previousPeriodOffsetSeconds,
isPreviousPeriod,
hiddenSeries = [],
logLevelColorFn = logLevelColor,
}: {
tsBucketMap: Map<number, Record<string, any>>;
lineDataMap: { [keyName: string]: LineDataWithOptionalColor };
Expand All @@ -529,6 +530,8 @@ function addResponseToFormattedData({
isPreviousPeriod: boolean;
previousPeriodOffsetSeconds: number;
hiddenSeries?: string[];
/** When set (e.g. HyperDX log charts), avoids reading nested Mantine vars from the DOM before Mantine updates them after a brand switch. */
logLevelColorFn?: typeof logLevelColor;
}) {
const { meta, data } = response;
if (meta == null) {
Expand Down Expand Up @@ -584,7 +587,7 @@ function addResponseToFormattedData({
// Special handling for log level / trace severity colors
let color: string | undefined = undefined;
if (firstGroupColumnIsLogLevel(source, groupColumns)) {
color = logLevelColor(row[groupColumns[0].name]);
color = logLevelColorFn(row[groupColumns[0].name]);
}

lineDataMap[keyName] = {
Expand All @@ -611,6 +614,7 @@ export function formatResponseForTimeChart({
source,
hiddenSeries = [],
previousPeriodOffsetSeconds = 0,
logLevelColorFn,
}: {
dateRange: [Date, Date];
granularity?: SQLInterval;
Expand All @@ -620,6 +624,7 @@ export function formatResponseForTimeChart({
source?: TSource;
hiddenSeries?: string[];
previousPeriodOffsetSeconds?: number;
logLevelColorFn?: typeof logLevelColor;
}) {
const meta = currentPeriodResponse.meta;

Expand Down Expand Up @@ -650,6 +655,8 @@ export function formatResponseForTimeChart({
[keyName: string]: LineDataWithOptionalColor;
} = {};

const resolveLogLevelColor = logLevelColorFn ?? logLevelColor;

addResponseToFormattedData({
response: currentPeriodResponse,
lineDataMap,
Expand All @@ -658,6 +665,7 @@ export function formatResponseForTimeChart({
isPreviousPeriod: false,
previousPeriodOffsetSeconds,
hiddenSeries,
logLevelColorFn: resolveLogLevelColor,
});

if (previousPeriodResponse != null) {
Expand All @@ -669,10 +677,11 @@ export function formatResponseForTimeChart({
isPreviousPeriod: true,
previousPeriodOffsetSeconds,
hiddenSeries,
logLevelColorFn: resolveLogLevelColor,
});
}

const logLevelColorOrder = getLogLevelColorOrder();
const logLevelColorOrder = getLogLevelColorOrder(resolveLogLevelColor);
const sortedLineData = Object.values(lineDataMap).sort((a, b) => {
return (
logLevelColorOrder.findIndex(color => color === a.color) -
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/__tests__/ChartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
formatResponseForPieChart,
formatResponseForTimeChart,
} from '@/ChartUtils';
import { COLORS, getChartColorError } from '@/utils';
import { COLORS, getChartColorError, getChartColorInfo } from '@/utils';

describe('ChartUtils', () => {
describe('formatResponseForTimeChart', () => {
Expand Down Expand Up @@ -306,7 +306,7 @@ describe('ChartUtils', () => {

expect(actual.lineData).toEqual([
{
color: COLORS[0],
color: getChartColorInfo(),
dataKey: 'info',
currentPeriodKey: 'info',
previousPeriodKey: 'info (previous)',
Expand All @@ -315,7 +315,7 @@ describe('ChartUtils', () => {
isDashed: false,
},
{
color: COLORS[0],
color: getChartColorInfo(),
dataKey: 'debug',
currentPeriodKey: 'debug',
previousPeriodKey: 'debug (previous)',
Expand Down
41 changes: 41 additions & 0 deletions packages/app/src/components/DBTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
Stack,
Text,
Tooltip,
useMantineColorScheme,
useMantineTheme,
} from '@mantine/core';
import { IconChartBar, IconChartLine, IconSearch } from '@tabler/icons-react';

Expand All @@ -41,6 +43,13 @@ import { MemoChart } from '@/HDXMultiSeriesTimeChart';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useChartNumberFormats, useSource } from '@/source';
import { useAppTheme } from '@/theme/ThemeProvider';
import {
getChartColorError,
getChartColorWarning,
getLogLevelClass,
logLevelColor,
} from '@/utils';

import ChartContainer from './charts/ChartContainer';
import ChartErrorState, {
Expand Down Expand Up @@ -286,6 +295,35 @@ function DBTimeChartComponent({
fillNulls,
} = useTimeChartSettings(config);

const { themeName } = useAppTheme();
const mantineTheme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const hyperdxGreen6 = mantineTheme.colors?.green?.[6];
const hyperdxGreen7 = mantineTheme.colors?.green?.[7];

/** HyperDX: `--color-chart-info` uses Mantine green-*; those CSS vars update after our DOM read on brand switch, so resolve from the live Mantine theme object instead. */
const resolveLogLevelColorForTimeChart = useMemo((): typeof logLevelColor => {
if (
themeName !== 'hyperdx' ||
hyperdxGreen6 == null ||
hyperdxGreen7 == null
) {
return logLevelColor;
}
const effectiveScheme = colorScheme ?? 'dark';
const infoHex = effectiveScheme === 'light' ? hyperdxGreen7 : hyperdxGreen6;
return (key: string | number | undefined) => {
const lvl = getLogLevelClass(`${key}`);
if (lvl === 'error') {
return getChartColorError();
}
if (lvl === 'warn') {
return getChartColorWarning();
}
return infoHex;
};
}, [themeName, colorScheme, hyperdxGreen6, hyperdxGreen7]);

const queriedConfig = useMemo(
() => convertToTimeChartConfig(config),
[config],
Expand Down Expand Up @@ -403,6 +441,7 @@ function DBTimeChartComponent({
source,
hiddenSeries,
previousPeriodOffsetSeconds,
logLevelColorFn: resolveLogLevelColorForTimeChart,
});
return {
...defaultResponse,
Expand All @@ -426,6 +465,8 @@ function DBTimeChartComponent({
previousPeriodData,
hiddenSeries,
previousPeriodOffsetSeconds,
// Brand + Mantine-sensitive log colors flow through `resolveLogLevelColorForTimeChart`.
resolveLogLevelColorForTimeChart,
]);

// To enable backward compatibility, allow non-controlled usage of displayType
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/components/__tests__/DBTimeChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ jest.mock('@/source', () => ({
.mockReturnValue({ formatByColumn: new Map(), chartFormat: undefined }),
}));

jest.mock('@/theme/ThemeProvider', () => ({
useAppTheme: jest.fn(() => ({
theme: { name: 'hyperdx' },
themeName: 'hyperdx',
availableThemes: ['hyperdx', 'clickstack'],
setTheme: jest.fn(),
toggleTheme: jest.fn(),
clearThemeOverride: jest.fn(),
isDev: false,
})),
}));

jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
Expand Down
48 changes: 30 additions & 18 deletions packages/app/src/theme/ChartColors.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,36 @@ import React from 'react';
import {
COLORS,
getChartColorError,
getChartColorInfo,
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',
];
// Categorical chart slots in canonical assignment order. The CSS var name
// is the kebab-case form of the camelCase key, and the human label is just
// the prettified version of the same (so adding a new slot only requires
// touching `CATEGORICAL_ORDER` in `utils.ts` and adding the matching var
// to `_chart-tokens.scss` — no story edits needed).
const CATEGORICAL_SLOTS = [
{ key: 'blue', cssSlug: 'blue', label: 'Blue (Primary)' },
{ key: 'orange', cssSlug: 'orange', label: 'Orange' },
{ key: 'red', cssSlug: 'red', label: 'Red' },
{ key: 'cyan', cssSlug: 'cyan', label: 'Cyan' },
{ key: 'green', cssSlug: 'green', label: 'Green' },
{ key: 'pink', cssSlug: 'pink', label: 'Pink' },
{ key: 'purple', cssSlug: 'purple', label: 'Purple' },
{ key: 'lightBlue', cssSlug: 'light-blue', label: 'Light Blue' },
{ key: 'brown', cssSlug: 'brown', label: 'Brown' },
{ key: 'gray', cssSlug: 'gray', label: 'Gray' },
] as const;

// Derive chart colors from the single source of truth in utils.ts
const CHART_COLORS = COLORS.map((hex, i) => ({
name: `color-chart-${i + 1}`,
hex,
label: COLOR_LABELS[i] || `Color ${i + 1}`,
// Derive chart colors from the single source of truth in utils.ts (COLORS
// is hex-by-position; CATEGORICAL_SLOTS is name-by-position; their lengths
// and ordering are kept in lockstep).
const CHART_COLORS = CATEGORICAL_SLOTS.map((slot, i) => ({
name: `color-chart-${slot.cssSlug}`,
hex: COLORS[i],
label: slot.label,
}));

const SEMANTIC_CHART_COLORS = [
Expand All @@ -34,6 +41,11 @@ const SEMANTIC_CHART_COLORS = [
hex: getChartColorSuccess(),
label: 'Success (Green)',
},
{
name: 'color-chart-info',
hex: getChartColorInfo(),
label: 'Info logs (HyperDX: green · ClickStack: blue)',
},
{
name: 'color-chart-warning',
hex: getChartColorWarning(),
Expand Down
44 changes: 19 additions & 25 deletions packages/app/src/theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import React, {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

Expand Down Expand Up @@ -55,10 +54,11 @@ export function AppThemeProvider({
// SSR/initial render: Always use props or DEFAULT_THEME for hydration consistency.
// The server cannot read localStorage, so we must start with a deterministic value.
//
// HYDRATION NOTE: In dev mode, the useEffect below may update the theme after hydration
// if localStorage contains a different theme. This is intentional for dev testing
// and will cause a brief flash. In production (IS_DEV=false), theme is stable and
// matches server render. To avoid any flash in production, pass themeName prop explicitly.
// HYDRATION NOTE: In dev mode, the useEffect that syncs localStorage may update
// the theme after hydration if localStorage contains a different theme. This is
// intentional for dev testing and will cause a brief flash. In production
// (IS_DEV=false), theme is stable and matches server render. To avoid any flash
// in production, pass themeName prop explicitly.
const [resolvedThemeName, setResolvedThemeName] = useState<ThemeName>(
() => propsThemeName ?? DEFAULT_THEME,
);
Expand Down Expand Up @@ -131,28 +131,22 @@ export function AppThemeProvider({
[theme, setTheme, toggleTheme, clearThemeOverride],
);

// Track previous theme class for efficient swap
const prevThemeClassRef = useRef<string | null>(null);

// Apply theme CSS class to document (single class swap for performance)
useEffect(() => {
if (typeof document !== 'undefined') {
const html = document.documentElement;
const newClass = theme.cssClass;

// Remove only the previous theme class (not all themes)
if (prevThemeClassRef.current && prevThemeClassRef.current !== newClass) {
html.classList.remove(prevThemeClassRef.current);
}

// Add new theme class if not already present
if (!html.classList.contains(newClass)) {
html.classList.add(newClass);
// Apply theme CSS class to <html> synchronously during render so descendants
// that read CSS variables in the same commit (e.g. chart formatting via
// getComputedStyle) see the correct theme. useEffect runs too late — after
// children have already rendered with the previous class still on the document.
if (typeof document !== 'undefined') {
const html = document.documentElement;
const nextClass = theme.cssClass;
for (const t of Object.values(themes)) {
if (t.cssClass !== nextClass) {
html.classList.remove(t.cssClass);
}

prevThemeClassRef.current = newClass;
}
}, [theme]);
if (!html.classList.contains(nextClass)) {
html.classList.add(nextClass);
}
}

// Dev mode: expose theme API to window (namespaced to avoid global pollution)
useEffect(() => {
Expand Down
Loading
Loading