+ Semantic chart colors are unified across HyperDX and ClickStack — + `success` aliases categorical `chart-green` and `info` aliases + `chart-blue`. Brand identity for charts is carried by non-chart UI chrome + (Mantine accent, sidebar gradient), not by these tokens. +
@@ -352,7 +372,7 @@ export const AccessibilityCheck = () => (
border: '1px solid var(--color-border)',
}}
>
- {CHART_COLORS.map(({ hex, label }, i) => (
+ {CHART_COLORS.map(({ hex, label }) => (
(
gap: 2,
}}
>
- {i + 1}
- {label}
+ {label}
))}
diff --git a/packages/app/src/theme/semanticColorsGrouped.ts b/packages/app/src/theme/semanticColorsGrouped.ts
index 19da9b1d4d..5c21f10527 100644
--- a/packages/app/src/theme/semanticColorsGrouped.ts
+++ b/packages/app/src/theme/semanticColorsGrouped.ts
@@ -57,19 +57,21 @@ export const semanticColorsGrouped = {
'color-json-punctuation',
],
charts: [
- 'color-chart-1',
- 'color-chart-2',
- 'color-chart-3',
- 'color-chart-4',
- 'color-chart-5',
- 'color-chart-6',
- 'color-chart-7',
- 'color-chart-8',
- 'color-chart-9',
- 'color-chart-10',
+ 'color-chart-blue',
+ 'color-chart-orange',
+ 'color-chart-red',
+ 'color-chart-cyan',
+ 'color-chart-green',
+ 'color-chart-pink',
+ 'color-chart-purple',
+ 'color-chart-light-blue',
+ 'color-chart-brown',
+ 'color-chart-gray',
'color-chart-success',
'color-chart-warning',
'color-chart-error',
+ 'color-chart-info',
+ 'color-chart-success-highlight',
'color-chart-error-highlight',
'color-chart-warning-highlight',
],
diff --git a/packages/app/src/theme/themes/_chart-categorical-tokens.scss b/packages/app/src/theme/themes/_chart-categorical-tokens.scss
new file mode 100644
index 0000000000..4c9454babf
--- /dev/null
+++ b/packages/app/src/theme/themes/_chart-categorical-tokens.scss
@@ -0,0 +1,62 @@
+/* Shared Chart Tokens - used by both theme files */
+
+/*
+ * Categorical hues and semantic chart colors are unified across HyperDX and
+ * ClickStack. This file is the SCSS source of truth for both layers; each
+ * brand theme `@use`s it and `@include`s the mixins inside its per-theme
+ * `chart-tokens` mixin (categorical first, then semantic — semantics reference
+ * `var(--color-chart-green)` / `var(--color-chart-blue)`).
+ *
+ * Per-brand overrides: if a future brand needs a different semantic hex,
+ * declare it in that theme's `chart-tokens` mixin *after* the shared
+ * `@include chart-semantic-tokens` call.
+ *
+ * Categorical hues — React reads these from `CATEGORICAL_HEX_BY_TOKEN` in
+ * `packages/app/src/utils.ts` directly (`getColorFromCSSVariable` /
+ * `getColorFromCSSToken` skip `getComputedStyle` for categorical tokens).
+ * The CSS vars exist for SCSS modules, inline `var()` consumers, and devtools.
+ * If you change a categorical hex here, update `CATEGORICAL_HEX_BY_TOKEN`
+ * in `utils.ts` to match.
+ *
+ * Semantic tokens — read at runtime via `getComputedStyle` in `utils.ts`.
+ * If you change a semantic hex here, update `SEMANTIC_CHART_PALETTE` in
+ * `utils.ts` to match.
+ *
+ * `chart-blue` is `#437eef` (the brand link color, same hue as
+ * `--click-global-color-text-link-default`) rather than Observable blue
+ * (`#4269d0`); all other hues are straight from Observable 10
+ * (https://observablehq.com/@d3/color-schemes).
+ */
+@mixin chart-categorical-tokens {
+ --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;
+}
+
+/*
+ * Semantic chart colors — unified on both brands. All four semantic vars
+ * alias their matching categorical hue var so a future tweak to e.g.
+ * `--color-chart-orange` propagates to `--color-chart-warning` without
+ * a second edit. Must be `@include`d after `chart-categorical-tokens`.
+ *
+ * Highlight variants stay as concrete hexes — they're a tint of the
+ * base hue, not an alias, so they can't ride the var.
+ */
+@mixin chart-semantic-tokens {
+ --color-chart-success: var(--color-chart-green);
+ --color-chart-warning: var(--color-chart-orange);
+ --color-chart-error: var(--color-chart-red);
+ --color-chart-info: var(--color-chart-blue);
+
+ /* Highlighted variants (hover/selection states) */
+ --color-chart-success-highlight: #80d9b3;
+ --color-chart-warning-highlight: #f5c94d;
+ --color-chart-error-highlight: #ffa090;
+}
diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss
index 0c77f5498c..f079c14bee 100644
--- a/packages/app/src/theme/themes/clickstack/_tokens.scss
+++ b/packages/app/src/theme/themes/clickstack/_tokens.scss
@@ -4,6 +4,23 @@
/* Uses Click UI design tokens */
+/*
+ * `chart-tokens` is split out so the chart palette is defined once,
+ * not duplicated across the dark/light blocks. Both scheme selectors
+ * @include it so specificity stays identical to declaring the vars
+ * inline — Sass inlines the body of the mixin at each call site, so
+ * the resulting CSS is byte-identical to the previous hand-duplicated
+ * shape. Categorical and semantic chart tokens come from the shared
+ * `chart-categorical-tokens` partial (two mixins: hues, then semantics).
+ */
+
+@use '../chart-categorical-tokens' as shared-chart;
+
+@mixin chart-tokens {
+ @include shared-chart.chart-categorical-tokens;
+ @include shared-chart.chart-semantic-tokens;
+}
+
/* Dark Mode */
.theme-clickstack[data-mantine-color-scheme='dark'] {
/* Brand Palette - Yellow/Gold */
@@ -172,33 +189,7 @@
--color-json-array: #ffd966;
--color-json-punctuation: #666980;
- /*
- * Chart Colors - Observable 10 categorical palette
- * NOTE: These colors are intentionally duplicated in both dark and light mode sections.
- * CSS specificity requires them to be defined within each [data-mantine-color-scheme] selector
- * to ensure they're applied correctly. A shared .theme-clickstack section would have lower
- * specificity and could be overridden by other styles.
- */
- --color-chart-1: #437eef; /* Blue - Primary */
- --color-chart-2: #efb118; /* Orange */
- --color-chart-3: #ff725c; /* Red */
- --color-chart-4: #6cc5b0; /* Cyan */
- --color-chart-5: #3ca951; /* Green */
- --color-chart-6: #ff8ab7; /* Pink */
- --color-chart-7: #a463f2; /* Purple */
- --color-chart-8: #97bbf5; /* Light Blue */
- --color-chart-9: #9c6b4e; /* Brown */
- --color-chart-10: #9498a0; /* Gray */
-
- /* Chart Semantic Colors */
- --color-chart-success: #3ca951; /* Green */
- --color-chart-warning: #efb118; /* Orange */
- --color-chart-error: #ff725c; /* Red */
-
- /* Chart Semantic Colors - Highlighted (for hover/selection states) */
- --color-chart-success-highlight: #80d9b3;
- --color-chart-warning-highlight: #f5c94d;
- --color-chart-error-highlight: #ffa090;
+ @include chart-tokens;
/* Mantine Overrides */
--mantine-color-body: var(--color-bg-body) !important;
@@ -379,27 +370,7 @@
--color-json-array: #997300;
--color-json-punctuation: #868e96;
- /* Chart Colors - See dark mode section for explanation of why duplication is required */
- --color-chart-1: #437eef; /* Blue - Primary */
- --color-chart-2: #efb118; /* Orange */
- --color-chart-3: #ff725c; /* Red */
- --color-chart-4: #6cc5b0; /* Cyan */
- --color-chart-5: #3ca951; /* Green */
- --color-chart-6: #ff8ab7; /* Pink */
- --color-chart-7: #a463f2; /* Purple */
- --color-chart-8: #97bbf5; /* Light Blue */
- --color-chart-9: #9c6b4e; /* Brown */
- --color-chart-10: #9498a0; /* Gray */
-
- /* Chart Semantic Colors */
- --color-chart-success: #3ca951; /* Green */
- --color-chart-warning: #efb118; /* Orange */
- --color-chart-error: #ff725c; /* Red */
-
- /* Chart Semantic Colors - Highlighted (for hover/selection states) */
- --color-chart-success-highlight: #80d9b3;
- --color-chart-warning-highlight: #f5c94d;
- --color-chart-error-highlight: #ffa090;
+ @include chart-tokens;
/* Mantine Overrides */
--mantine-color-body: var(--color-bg-body) !important;
diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss
index 1503bbc4ae..6549aee8c6 100644
--- a/packages/app/src/theme/themes/hyperdx/_tokens.scss
+++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss
@@ -6,8 +6,23 @@
* These mixins define all design tokens for HyperDX theme.
* They are used both here (for .theme-hyperdx scoped selectors) and
* in _base-tokens.scss (for unscoped fallback selectors during SSR).
+ *
+ * `chart-tokens` is split out so the chart palette is defined once,
+ * not duplicated across the dark/light blocks. Both dark and light
+ * @include it so specificity stays identical to declaring the vars
+ * inline — Sass inlines the body of the mixin at each call site, so
+ * the resulting CSS is byte-identical to the previous hand-duplicated
+ * shape. Categorical and semantic chart tokens come from the shared
+ * `chart-categorical-tokens` partial (two mixins: hues, then semantics).
*/
+@use '../chart-categorical-tokens' as shared-chart;
+
+@mixin chart-tokens {
+ @include shared-chart.chart-categorical-tokens;
+ @include shared-chart.chart-semantic-tokens;
+}
+
@mixin dark-mode-tokens {
/* Backgrounds */
--color-bg-body: var(--mantine-color-dark-9);
@@ -92,29 +107,7 @@
--color-json-array: var(--mantine-color-green-3);
--color-json-punctuation: var(--mantine-color-dark-4);
- /* Chart Colors - Brand green first, then Observable palette
- Note: 1-indexed CSS vars map to 0-indexed COLORS array in utils.ts
- e.g., --color-chart-1 corresponds to COLORS[0] */
- --color-chart-1: #00c28a; /* Green - Brand (primary) */
- --color-chart-2: #4269d0; /* Blue */
- --color-chart-3: #efb118; /* Orange */
- --color-chart-4: #ff725c; /* Red */
- --color-chart-5: #6cc5b0; /* Cyan */
- --color-chart-6: #ff8ab7; /* Pink */
- --color-chart-7: #a463f2; /* Purple */
- --color-chart-8: #97bbf5; /* Light Blue */
- --color-chart-9: #9c6b4e; /* Brown */
- --color-chart-10: #9498a0; /* Gray */
-
- /* Chart Semantic Colors */
- --color-chart-success: #00c28a; /* Green - Brand */
- --color-chart-warning: #efb118; /* Orange */
- --color-chart-error: #ff725c; /* Red */
-
- /* Chart Semantic Colors - Highlighted (for hover/selection states) */
- --color-chart-success-highlight: #80d9b3;
- --color-chart-warning-highlight: #f5c94d;
- --color-chart-error-highlight: #ffa090;
+ @include chart-tokens;
/* Mantine Overrides */
--mantine-color-body: var(--color-bg-body) !important;
@@ -206,27 +199,7 @@
--color-json-array: var(--mantine-color-green-7);
--color-json-punctuation: var(--mantine-color-dark-5);
- /* Chart Colors - Brand green first, then Observable palette (same for light mode) */
- --color-chart-1: #00c28a; /* Green - Brand (primary) */
- --color-chart-2: #4269d0; /* Blue */
- --color-chart-3: #efb118; /* Orange */
- --color-chart-4: #ff725c; /* Red */
- --color-chart-5: #6cc5b0; /* Cyan */
- --color-chart-6: #ff8ab7; /* Pink */
- --color-chart-7: #a463f2; /* Purple */
- --color-chart-8: #97bbf5; /* Light Blue */
- --color-chart-9: #9c6b4e; /* Brown */
- --color-chart-10: #9498a0; /* Gray */
-
- /* Chart Semantic Colors */
- --color-chart-success: #00c28a; /* Green - Brand */
- --color-chart-warning: #efb118; /* Orange */
- --color-chart-error: #ff725c; /* Red */
-
- /* Chart Semantic Colors - Highlighted (for hover/selection states) */
- --color-chart-success-highlight: #80d9b3;
- --color-chart-warning-highlight: #f5c94d;
- --color-chart-error-highlight: #ffa090;
+ @include chart-tokens;
/* Mantine Overrides */
--mantine-color-body: var(--color-bg-body);
diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts
index e80d6a25ee..8bbac30acf 100644
--- a/packages/app/src/utils.ts
+++ b/packages/app/src/utils.ts
@@ -6,6 +6,7 @@ import type { SetStateAction } from 'react';
import TimestampNano from 'timestamp-nano';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import {
+ CATEGORICAL_PALETTE_TOKENS,
ChartPaletteToken,
NumericUnit,
SourceKind,
@@ -354,60 +355,128 @@ export const getLogLevelClass = (lvl: string | undefined) => {
: undefined;
};
-// Chart color palette - single source of truth
-// Colors from Observable categorical palette, with custom brand green
-// https://observablehq.com/@d3/color-schemes
-const CHART_PALETTE = {
- green: '#00c28a', // Brand green (Mantine green.5) - used as primary chart color
- blue: '#4269d0',
- orange: '#efb118',
- red: '#ff725c',
- cyan: '#6cc5b0',
- pink: '#ff8ab7',
- purple: '#a463f2',
- lightBlue: '#97bbf5',
- brown: '#9c6b4e',
- gray: '#9498a0',
- // Highlighted variants (lighter shades for hover/selection states)
- greenHighlight: '#80d9b3',
- redHighlight: '#ffa090',
- orangeHighlight: '#f5c94d',
-} as const;
-
-// ClickStack theme chart color palette - Observable 10 categorical palette
-// https://observablehq.com/@d3/color-schemes
-const CLICKSTACK_CHART_PALETTE = {
- blue: '#437EEF', // Primary color for ClickStack
- orange: '#efb118',
- red: '#ff725c',
- cyan: '#6cc5b0',
- green: '#3ca951',
- pink: '#ff8ab7',
- purple: '#a463f2',
- lightBlue: '#97bbf5',
- brown: '#9c6b4e',
- gray: '#9498a0',
- // Highlighted variants (lighter shades for hover/selection states)
- greenHighlight: '#80d9b3',
- redHighlight: '#ffa090',
- orangeHighlight: '#f5c94d',
-} as const;
-
-// Ordered array for chart series - green first for brand consistency (HyperDX default)
-// Maps to CSS variables: COLORS[0] -> --color-chart-1, COLORS[1] -> --color-chart-2, etc.
-// NOTE: This is a fallback for SSR. In browser, getColorFromCSSVariable() reads from CSS variables
-export const COLORS = [
- CHART_PALETTE.green, // 1 - Brand green (primary) - HyperDX default
- CHART_PALETTE.blue, // 2
- CHART_PALETTE.orange, // 3
- CHART_PALETTE.red, // 4
- CHART_PALETTE.cyan, // 5
- CHART_PALETTE.pink, // 6
- CHART_PALETTE.purple, // 7
- CHART_PALETTE.lightBlue, // 8
- CHART_PALETTE.brown, // 9
- CHART_PALETTE.gray, // 10
-];
+/**
+ * Chart Categorical Palette - ten distinguishable hues based on
+ * Observable 10 (https://observablehq.com/@d3/color-schemes), with
+ * `chart-blue` swapped to `#437eef` to match the brand link color
+ * (`--click-global-color-text-link-default`). All other hues are
+ * straight from Observable 10. Unified across themes.
+ *
+ * **JS is the source of truth for categorical hues.** Both HyperDX and
+ * ClickStack resolve `--color-chart-{hue}` to the same hex today, so
+ * the JS readers (`getColorFromCSSVariable`, `getColorFromCSSToken`)
+ * return values directly from this object without round-tripping
+ * through `getComputedStyle`. The matching `--color-chart-{hue}` CSS
+ * vars in `_tokens.scss` exist as a stylesheet-author affordance only
+ * (inline `var()` use, devtools inspection); they are NOT read back by
+ * the React rendering path. Per-brand identity is carried by the
+ * semantic chart tokens below and by non-chart UI chrome (Mantine
+ * accent, sidebar gradient, etc.).
+ *
+ * Keep in sync with `CATEGORICAL_PALETTE_TOKENS` in
+ * `@hyperdx/common-utils/dist/types` and with the `--color-chart-{hue}`
+ * vars in `packages/app/src/theme/themes/_chart-categorical-tokens.scss`
+ * (the single shared SCSS source for categorical hues; both brand
+ * themes `@use` it).
+ */
+type CategoricalChartPaletteToken = (typeof CATEGORICAL_PALETTE_TOKENS)[number];
+
+const CATEGORICAL_HEX_BY_TOKEN = {
+ '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',
+} as const satisfies Record;
+
+// Reverse-direction completeness check: if `CATEGORICAL_HEX_BY_TOKEN`
+// ever grows an extra key that's not in `CATEGORICAL_PALETTE_TOKENS`
+// (e.g. a deprecated hex stuck around after dropping the token from the
+// shared enum), this type collapses to `never` and the assignment below
+// becomes a compile error. The `satisfies` above already enforces the
+// forward direction (every categorical token has a hex), so together
+// they pin the two structures to a 1:1 mapping at build time.
+type _CategoricalHexCompleteness =
+ Exclude<
+ keyof typeof CATEGORICAL_HEX_BY_TOKEN,
+ CategoricalChartPaletteToken
+ > extends never
+ ? true
+ : never;
+const _categoricalHexCompletenessCheck: _CategoricalHexCompleteness = true;
+void _categoricalHexCompletenessCheck;
+
+type SemanticChartColorKey =
+ | 'success'
+ | 'warning'
+ | 'error'
+ | 'info'
+ | 'successHighlight'
+ | 'warningHighlight'
+ | 'errorHighlight';
+
+/**
+ * Per-brand semantic chart palette. SSR / `getComputedStyle` fallback
+ * for `getChartColor{Success,Warning,Error,Info,*Highlight}` helpers.
+ * Live values come from `--color-chart-{success|warning|error|info}[-highlight]`
+ * in `_chart-categorical-tokens.scss` (`chart-semantic-tokens` mixin).
+ *
+ * Kept per-brand (instead of collapsed to one object) so the
+ * `Record<'hyperdx' | 'clickstack', SemanticChartHexes>` constraint
+ * forces both brands to declare every semantic key — dropping or
+ * renaming one in either entry becomes a compile error rather than a
+ * silent runtime divergence between SSR and client. The two entries
+ * are byte-identical today; collapse to a flat object if and when a
+ * brand actually needs to diverge.
+ */
+type SemanticChartHexes = Readonly>;
+
+const SEMANTIC_CHART_PALETTE: Readonly<
+ Record<'hyperdx' | 'clickstack', SemanticChartHexes>
+> = {
+ hyperdx: {
+ success: '#3ca951',
+ warning: '#efb118',
+ error: '#ff725c',
+ info: '#437eef',
+ successHighlight: '#80d9b3',
+ warningHighlight: '#f5c94d',
+ errorHighlight: '#ffa090',
+ },
+ clickstack: {
+ success: '#3ca951',
+ warning: '#efb118',
+ error: '#ff725c',
+ info: '#437eef',
+ successHighlight: '#80d9b3',
+ warningHighlight: '#f5c94d',
+ errorHighlight: '#ffa090',
+ },
+};
+
+/**
+ * Ordered hex array for positional series assignment.
+ * `COLORS[i]` === `CATEGORICAL_HEX_BY_TOKEN[CATEGORICAL_PALETTE_TOKENS[i]]`.
+ * Returned directly by `getColorFromCSSVariable(i)` on both server and
+ * client — the categorical palette is unified across themes, so there's
+ * no benefit to reading the matching CSS var via `getComputedStyle`.
+ *
+ * Typed as `readonly string[]` (not `string[]`) because the array is a
+ * derived snapshot of `CATEGORICAL_HEX_BY_TOKEN` — mutating it in place
+ * would desync the two structures the completeness check above pins
+ * together. `CATEGORICAL_PALETTE_TOKENS` is a `readonly` tuple of
+ * `CategoricalChartPaletteToken` already, so the `.map` callback's
+ * `token` parameter is the narrow union and `CATEGORICAL_HEX_BY_TOKEN`
+ * index lookup is exhaustive without further assertion.
+ */
+export const COLORS: readonly string[] = CATEGORICAL_PALETTE_TOKENS.map(
+ token => CATEGORICAL_HEX_BY_TOKEN[token],
+);
/**
* Palette token types and runtime guards live in common-utils so the
@@ -421,7 +490,7 @@ export const COLORS = [
export {
CATEGORICAL_PALETTE_TOKENS,
CHART_PALETTE_TOKENS,
- isChartPaletteToken,
+ resolveChartPaletteToken,
SEMANTIC_PALETTE_TOKENS,
} from '@hyperdx/common-utils/dist/types';
export type { ChartPaletteToken };
@@ -448,59 +517,53 @@ function detectActiveTheme(): 'clickstack' | 'hyperdx' {
}
/**
- * Reads chart color from CSS variable based on index.
- * CSS variables handle theme switching automatically via theme classes on documentElement.
- * Falls back to COLORS array if CSS variable is not available (SSR or getComputedStyle fails).
+ * Returns the Nth categorical chart hex by series index. Index wraps
+ * modulo `CATEGORICAL_PALETTE_TOKENS.length`.
*
- * Note on SSR/Hydration: During SSR, this returns fallback colors (HyperDX green palette).
- * On client hydration, it reads from CSS variables which may differ for ClickStack theme.
- * This is expected behavior - charts typically render after data fetching (client-side),
- * so hydration mismatches are rare. If needed, wrap chart components with suppressHydrationWarning.
+ * Reads from the JS palette directly. The matching `--color-chart-{hue}`
+ * CSS var resolves to the same hex on every theme today, so the
+ * previous `getComputedStyle` round-trip added a layout read per series
+ * with no functional benefit. If a future brand wants to override hues,
+ * reintroduce the DOM read here (and add per-brand entries to
+ * `CATEGORICAL_HEX_BY_TOKEN`).
*/
function getColorFromCSSVariable(index: number): string {
- const colorArrayLength = COLORS.length;
-
- if (typeof window === 'undefined') {
- // SSR: fallback to default colors (HyperDX palette)
- return COLORS[index % colorArrayLength];
- }
-
- try {
- const cssVarName = `--color-chart-${(index % colorArrayLength) + 1}`;
- // Read from documentElement - CSS variables cascade from theme classes
- const computedStyle = getComputedStyle(document.documentElement);
- const color = computedStyle.getPropertyValue(cssVarName).trim();
-
- // Only use CSS variable if it's actually set (non-empty)
- if (color && color !== '') {
- return color;
- }
- } catch {
- // Fallback if getComputedStyle fails
- }
-
- // Fallback to default colors
- return COLORS[index % colorArrayLength];
+ const i = index % CATEGORICAL_PALETTE_TOKENS.length;
+ return COLORS[i];
}
/**
- * Reads a chart color from a palette token by resolving the matching
- * CSS variable (`--color-chart-` for categorical
- * tokens, `--color-chart-{success|warning|error}` for semantic tokens).
+ * Resolves a chart palette token to a hex string.
+ *
+ * Categorical hue tokens (`chart-blue`, `chart-orange`, ...) come
+ * straight from `CATEGORICAL_HEX_BY_TOKEN` — the palette is unified
+ * across themes, so the matching `--color-chart-{hue}` CSS var would
+ * always resolve to the same value, and skipping `getComputedStyle`
+ * avoids an unnecessary layout read per series.
*
- * Falls back to the appropriate slot in `COLORS` (categorical) or the
- * HyperDX semantic palette (success / warning / error) when running
- * server-side or when `getComputedStyle` fails.
+ * Semantic tokens (`chart-success`, `-warning`, `-error`) DO vary per
+ * brand, so they read the matching CSS var (`--color-chart-{name}`)
+ * via `getComputedStyle` and fall back to the active theme's entry in
+ * `SEMANTIC_CHART_PALETTE` for SSR / `getComputedStyle` failures.
*
* @example
- * getColorFromCSSToken('chart-1') // brand green
+ * getColorFromCSSToken('chart-blue') // Observable blue (both themes)
* getColorFromCSSToken('chart-warning') // theme-aware warning
*/
export function getColorFromCSSToken(token: ChartPaletteToken): string {
- const cssVarName = `--color-${token}`;
+ if (isCategoricalChartPaletteToken(token)) {
+ return CATEGORICAL_HEX_BY_TOKEN[token];
+ }
+
+ // After the categorical short-circuit, `token` is narrowed to a
+ // semantic token (`chart-success`/`chart-warning`/`chart-error`)
+ // — the parameter type that `semanticTokenFallback` enforces via
+ // its exhaustiveness check.
+ const semanticToken = token;
+ const cssVarName = `--color-${semanticToken}`;
if (typeof window === 'undefined') {
- return paletteTokenSSRFallback(token);
+ return semanticTokenFallback(semanticToken);
}
try {
@@ -513,21 +576,38 @@ export function getColorFromCSSToken(token: ChartPaletteToken): string {
// Fallback if getComputedStyle fails
}
- return paletteTokenSSRFallback(token);
+ return semanticTokenFallback(semanticToken);
}
-function paletteTokenSSRFallback(token: ChartPaletteToken): string {
+function isCategoricalChartPaletteToken(
+ token: ChartPaletteToken,
+): token is keyof typeof CATEGORICAL_HEX_BY_TOKEN {
+ return Object.prototype.hasOwnProperty.call(CATEGORICAL_HEX_BY_TOKEN, token);
+}
+
+function semanticTokenFallback(
+ token: Exclude,
+): string {
switch (token) {
case 'chart-success':
- return CHART_PALETTE.green;
case 'chart-warning':
- return CHART_PALETTE.orange;
- case 'chart-error':
- return CHART_PALETTE.red;
+ case 'chart-error': {
+ const theme = detectActiveTheme();
+ const key = token.slice('chart-'.length) as
+ | 'success'
+ | 'warning'
+ | 'error';
+ return SEMANTIC_CHART_PALETTE[theme][key];
+ }
default: {
- // Categorical token: chart-N where N is 1..10
- const index = Number(token.slice('chart-'.length)) - 1;
- return COLORS[index] ?? COLORS[0];
+ // Exhaustiveness assertion: if a new semantic token lands on
+ // `ChartPaletteToken` without a matching case above, this line
+ // becomes a compile error. The fallback was previously
+ // `COLORS[0]` with a "brand-primary" comment, but that path is
+ // unreachable through the parameter type and silently masked
+ // future drift.
+ const _exhaustive: never = token;
+ throw new Error(`Unhandled semantic chart token: ${_exhaustive}`);
}
}
}
@@ -546,21 +626,19 @@ export function hashCode(str: string) {
}
/**
- * Gets theme-aware chart color from CSS variable or falls back to palette.
- * Reads from --color-chart-{type} CSS variable, falls back to theme-appropriate palette.
+ * Theme-aware semantic chart color resolver. Reads `cssVarName` from
+ * `documentElement` and falls back to the active theme's value from
+ * `SEMANTIC_CHART_PALETTE` (HyperDX during SSR).
*
- * Note on SSR/Hydration: During SSR, returns HyperDX colors as default.
- * On client, reads from CSS variables for accurate theme colors.
- * Charts typically render client-side after data fetching, minimizing hydration issues.
+ * Charts typically render client-side after data fetching, so hydration
+ * mismatches between the SSR fallback and the live var are rare.
*/
function getSemanticChartColor(
cssVarName: string,
- hyperdxColor: string,
- clickstackColor: string,
+ key: SemanticChartColorKey,
): string {
if (typeof window === 'undefined') {
- // SSR: use HyperDX as default (can't detect theme without DOM)
- return hyperdxColor;
+ return SEMANTIC_CHART_PALETTE.hyperdx[key];
}
try {
@@ -573,59 +651,48 @@ function getSemanticChartColor(
// Fallback if getComputedStyle fails
}
- // Fallback to theme-appropriate palette
- const activeTheme = detectActiveTheme();
- return activeTheme === 'clickstack' ? clickstackColor : hyperdxColor;
+ return SEMANTIC_CHART_PALETTE[detectActiveTheme()][key];
}
-// Semantic colors for log levels (theme-aware)
-// These are functions that read from CSS variables with theme-appropriate fallbacks
+// Semantic chart colors (theme-aware). Read from CSS variables with
+// per-theme fallbacks in `SEMANTIC_CHART_PALETTE`.
export function getChartColorSuccess(): string {
- return getSemanticChartColor(
- '--color-chart-success',
- CHART_PALETTE.green,
- CLICKSTACK_CHART_PALETTE.green,
- );
+ return getSemanticChartColor('--color-chart-success', 'success');
}
export function getChartColorWarning(): string {
- return getSemanticChartColor(
- '--color-chart-warning',
- CHART_PALETTE.orange,
- CLICKSTACK_CHART_PALETTE.orange,
- );
+ return getSemanticChartColor('--color-chart-warning', 'warning');
}
export function getChartColorError(): string {
- return getSemanticChartColor(
- '--color-chart-error',
- CHART_PALETTE.red,
- CLICKSTACK_CHART_PALETTE.red,
- );
+ return getSemanticChartColor('--color-chart-error', 'error');
+}
+
+/** Chart blue used for info-level logs and similar "neutral / default"
+ * series. Same hue as categorical `chart-blue` on both brands. */
+export function getChartColorInfo(): string {
+ return getSemanticChartColor('--color-chart-info', 'info');
}
// Highlighted variants (theme-aware)
export function getChartColorSuccessHighlight(): string {
return getSemanticChartColor(
'--color-chart-success-highlight',
- CHART_PALETTE.greenHighlight,
- CLICKSTACK_CHART_PALETTE.greenHighlight,
+ 'successHighlight',
);
}
export function getChartColorErrorHighlight(): string {
return getSemanticChartColor(
'--color-chart-error-highlight',
- CHART_PALETTE.redHighlight,
- CLICKSTACK_CHART_PALETTE.redHighlight,
+ 'errorHighlight',
);
}
export function getChartColorWarningHighlight(): string {
return getSemanticChartColor(
'--color-chart-warning-highlight',
- CHART_PALETTE.orangeHighlight,
- CLICKSTACK_CHART_PALETTE.orangeHighlight,
+ 'warningHighlight',
);
}
@@ -640,8 +707,7 @@ export const semanticKeyedColor = (
? getChartColorError()
: logLevel === 'warn'
? getChartColorWarning()
- : // Info-level logs use primary chart color (blue for ClickStack, green for HyperDX)
- getColorFromCSSVariable(0);
+ : getChartColorInfo();
}
// Use CSS variable for theme-aware colors, fallback to hardcoded array
@@ -654,8 +720,7 @@ export const logLevelColor = (key: string | number | undefined) => {
? getChartColorError()
: logLevel === 'warn'
? getChartColorWarning()
- : // Info-level logs use primary chart color (blue for ClickStack, green for HyperDX)
- getColorFromCSSVariable(0);
+ : getChartColorInfo();
};
// order of colors for sorting. primary color (blue/green) on bottom, then yellow, then red
@@ -672,8 +737,7 @@ const getLevelColor = (logLevel?: string) => {
? getChartColorError()
: logLevel === 'warn'
? getChartColorWarning()
- : // Info-level logs use primary chart color (blue for ClickStack, green for HyperDX)
- getColorFromCSSVariable(0);
+ : getChartColorInfo();
};
export const getColorProps = (index: number, level: string): string => {
diff --git a/packages/common-utils/src/__tests__/guards.test.ts b/packages/common-utils/src/__tests__/guards.test.ts
index 0b9ee56241..889a84cae5 100644
--- a/packages/common-utils/src/__tests__/guards.test.ts
+++ b/packages/common-utils/src/__tests__/guards.test.ts
@@ -3,7 +3,13 @@ import {
isBuilderSavedChartConfig,
isRawSqlSavedChartConfig,
} from '@/guards';
-import { DisplayType, isChartPaletteToken } from '@/types';
+import {
+ ChartPaletteTokenSchema,
+ DisplayType,
+ isChartPaletteToken,
+ resolveChartPaletteToken,
+ walkRawDashboardTileColors,
+} from '@/types';
describe('isRawSqlSavedChartConfig', () => {
it('returns true when configType is "sql"', () => {
@@ -170,9 +176,21 @@ describe('displayTypeRequiresSource', () => {
});
describe('isChartPaletteToken', () => {
- it('returns true for all categorical tokens', () => {
- for (let i = 1; i <= 10; i++) {
- expect(isChartPaletteToken(`chart-${i}`)).toBe(true);
+ it('returns true for every hue-named categorical token', () => {
+ const hues = [
+ 'chart-blue',
+ 'chart-orange',
+ 'chart-red',
+ 'chart-cyan',
+ 'chart-green',
+ 'chart-pink',
+ 'chart-purple',
+ 'chart-light-blue',
+ 'chart-brown',
+ 'chart-gray',
+ ];
+ for (const token of hues) {
+ expect(isChartPaletteToken(token)).toBe(true);
}
});
@@ -182,6 +200,15 @@ describe('isChartPaletteToken', () => {
expect(isChartPaletteToken('chart-error')).toBe(true);
});
+ it('returns false for legacy numeric tokens (handled by resolveChartPaletteToken, not the guard)', () => {
+ // The guard checks the current ChartPaletteToken enum strictly.
+ // Migration of legacy `chart-1`..`chart-10` is owned by
+ // `resolveChartPaletteToken` (render-time) and
+ // `normalizeDashboardTileColors` (fetch-time, in the app package).
+ expect(isChartPaletteToken('chart-1')).toBe(false);
+ expect(isChartPaletteToken('chart-10')).toBe(false);
+ });
+
it('returns false for a raw hex string', () => {
expect(isChartPaletteToken('#00c28a')).toBe(false);
expect(isChartPaletteToken('#ff725c')).toBe(false);
@@ -202,14 +229,14 @@ describe('isChartPaletteToken', () => {
});
it('is case-sensitive (no uppercase matches)', () => {
- expect(isChartPaletteToken('Chart-1')).toBe(false);
+ expect(isChartPaletteToken('Chart-Blue')).toBe(false);
expect(isChartPaletteToken('CHART-SUCCESS')).toBe(false);
expect(isChartPaletteToken('chart-Success')).toBe(false);
});
- it('returns false for an out-of-range categorical index', () => {
- expect(isChartPaletteToken('chart-0')).toBe(false);
- expect(isChartPaletteToken('chart-11')).toBe(false);
+ it('returns false for an out-of-range categorical hue', () => {
+ expect(isChartPaletteToken('chart-magenta')).toBe(false);
+ expect(isChartPaletteToken('chart-teal')).toBe(false);
});
it('returns false for strings that look similar but are not tokens', () => {
@@ -218,3 +245,132 @@ describe('isChartPaletteToken', () => {
expect(isChartPaletteToken('chart-neutral')).toBe(false);
});
});
+
+describe('ChartPaletteTokenSchema', () => {
+ it('accepts current hue-named and semantic tokens', () => {
+ expect(ChartPaletteTokenSchema.parse('chart-blue')).toBe('chart-blue');
+ expect(ChartPaletteTokenSchema.parse('chart-light-blue')).toBe(
+ 'chart-light-blue',
+ );
+ expect(ChartPaletteTokenSchema.parse('chart-success')).toBe(
+ 'chart-success',
+ );
+ });
+
+ it('rejects legacy chart-1..10 — migration is owned by the app-side normalizer, not the schema', () => {
+ // Keeping the schema strict (no `z.preprocess`) keeps its `z.input`
+ // type equal to its `z.output` type. Wrapping the enum in
+ // `z.preprocess` would force the input to `unknown`, which
+ // poisons `validateRequest`'s `req.body` inference all the way
+ // up to `Dashboard.tiles[i].config.color`. Stored legacy values
+ // are healed at fetch time by `normalizeDashboardTileColors` in
+ // `packages/app/src/dashboard.ts`.
+ expect(() => ChartPaletteTokenSchema.parse('chart-1')).toThrow();
+ expect(() => ChartPaletteTokenSchema.parse('chart-10')).toThrow();
+ });
+
+ it('rejects unknown strings', () => {
+ expect(() => ChartPaletteTokenSchema.parse('chart-magenta')).toThrow();
+ expect(() => ChartPaletteTokenSchema.parse('chart-11')).toThrow();
+ expect(() => ChartPaletteTokenSchema.parse('#ff0000')).toThrow();
+ });
+});
+
+describe('resolveChartPaletteToken', () => {
+ it('returns hue-named tokens unchanged', () => {
+ expect(resolveChartPaletteToken('chart-blue')).toBe('chart-blue');
+ expect(resolveChartPaletteToken('chart-green')).toBe('chart-green');
+ expect(resolveChartPaletteToken('chart-light-blue')).toBe(
+ 'chart-light-blue',
+ );
+ });
+
+ it('returns semantic tokens unchanged', () => {
+ expect(resolveChartPaletteToken('chart-success')).toBe('chart-success');
+ expect(resolveChartPaletteToken('chart-warning')).toBe('chart-warning');
+ expect(resolveChartPaletteToken('chart-error')).toBe('chart-error');
+ });
+
+ it('migrates legacy chart-1..10 to their HyperDX-slot-order hue equivalents', () => {
+ expect(resolveChartPaletteToken('chart-1')).toBe('chart-green');
+ expect(resolveChartPaletteToken('chart-2')).toBe('chart-blue');
+ expect(resolveChartPaletteToken('chart-3')).toBe('chart-orange');
+ expect(resolveChartPaletteToken('chart-4')).toBe('chart-red');
+ expect(resolveChartPaletteToken('chart-5')).toBe('chart-cyan');
+ expect(resolveChartPaletteToken('chart-6')).toBe('chart-pink');
+ expect(resolveChartPaletteToken('chart-7')).toBe('chart-purple');
+ expect(resolveChartPaletteToken('chart-8')).toBe('chart-light-blue');
+ expect(resolveChartPaletteToken('chart-9')).toBe('chart-brown');
+ expect(resolveChartPaletteToken('chart-10')).toBe('chart-gray');
+ });
+
+ it('returns undefined for unknown strings, hex values, and non-strings', () => {
+ expect(resolveChartPaletteToken('chart-magenta')).toBeUndefined();
+ expect(resolveChartPaletteToken('chart-11')).toBeUndefined();
+ expect(resolveChartPaletteToken('#ff0000')).toBeUndefined();
+ expect(resolveChartPaletteToken('')).toBeUndefined();
+ expect(resolveChartPaletteToken(undefined)).toBeUndefined();
+ expect(resolveChartPaletteToken(null)).toBeUndefined();
+ expect(resolveChartPaletteToken(1)).toBeUndefined();
+ });
+});
+
+// `walkRawDashboardTileColors` is the single shared per-tile traversal
+// behind four sites: the React app's fetch- and write-time normalizer,
+// the JSON-import pre-validation pass, the API dashboards-route
+// middleware, and the dashboard provisioner. The tests below pin its
+// contract directly; the per-callsite suites exercise the policy
+// composed over it (legacy → hue, unknown preserved, etc).
+describe('walkRawDashboardTileColors', () => {
+ const identity = (current: string) => current;
+
+ it('rewrites string colors via onColor', () => {
+ const input = { tiles: [{ config: { color: 'chart-1' } }] };
+ const result = walkRawDashboardTileColors(input, () => 'chart-green') as {
+ tiles: Array<{ config: { color: string } }>;
+ };
+ expect(result.tiles[0].config.color).toBe('chart-green');
+ });
+
+ it('strips the color field when onColor returns undefined', () => {
+ const input = {
+ tiles: [{ id: 't1', config: { color: 'bad', other: 'kept' } }],
+ };
+ const result = walkRawDashboardTileColors(input, () => undefined) as {
+ tiles: Array<{ id: string; config: { color?: string; other?: string } }>;
+ };
+ expect(result.tiles[0].config).not.toHaveProperty('color');
+ expect(result.tiles[0].config.other).toBe('kept');
+ expect(result.tiles[0].id).toBe('t1');
+ });
+
+ it('preserves referential identity when the callback returns the same string', () => {
+ const input = { tiles: [{ config: { color: 'chart-orange' } }] };
+ expect(walkRawDashboardTileColors(input, identity)).toBe(input);
+ });
+
+ it('skips tiles whose config has a non-string color', () => {
+ const input = { tiles: [{ config: { color: 42 } }] };
+ expect(walkRawDashboardTileColors(input, () => 'chart-blue')).toBe(input);
+ });
+
+ it('returns the input unchanged when tiles is missing, non-array, null, or a primitive', () => {
+ expect(walkRawDashboardTileColors({ name: 'D' }, identity)).toEqual({
+ name: 'D',
+ });
+ expect(walkRawDashboardTileColors({ tiles: 'nope' }, identity)).toEqual({
+ tiles: 'nope',
+ });
+ expect(walkRawDashboardTileColors(null, identity)).toBeNull();
+ expect(walkRawDashboardTileColors('hello', identity)).toBe('hello');
+ expect(walkRawDashboardTileColors(undefined, identity)).toBeUndefined();
+ });
+
+ it('handles tiles whose config is missing or non-object', () => {
+ const input = {
+ tiles: [{ id: 'a' }, { id: 'b', config: null }, { id: 'c', config: 7 }],
+ };
+ // No color fields to touch → identity output.
+ expect(walkRawDashboardTileColors(input, () => 'x')).toBe(input);
+ });
+});
diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts
index 4f7eab990b..55236f12d6 100644
--- a/packages/common-utils/src/types.ts
+++ b/packages/common-utils/src/types.ts
@@ -795,8 +795,13 @@ export function isOnClickDashboardById(
*
* Tokens map to CSS variables in
* `packages/app/src/theme/themes//_tokens.scss`:
- * chart-1 .. chart-10 -> --color-chart-1 .. --color-chart-10
- * chart-success/warning/error -> --color-chart-{success|warning|error}
+ * chart-{hue} -> --color-chart-{hue} (10 hues, unified across themes)
+ * chart-success/warning/error -> --color-chart-{success|warning|error} (semantic; unified across brands)
+ *
+ * `chart-info` is a render-time CSS variable (defined in the shared
+ * `chart-semantic-tokens` SCSS mixin) but is intentionally *not* in the
+ * picker enum — it's consumed only by code paths that always want
+ * brand-primary (e.g. info-level log series, `getChartColorInfo()`).
*
* Storing tokens (not hex) lets user choices reflow correctly across
* themes and color modes; see notes/repo-conventions/hyperdx/tile-styling.md.
@@ -806,34 +811,87 @@ export function isOnClickDashboardById(
* stays in `packages/app/src/utils.ts` because it depends on
* `getComputedStyle(document.documentElement)`.
*/
-export const CHART_PALETTE_TOKENS = [
- 'chart-1',
- 'chart-2',
- 'chart-3',
- 'chart-4',
- 'chart-5',
- 'chart-6',
- 'chart-7',
- 'chart-8',
- 'chart-9',
- 'chart-10',
+/** Categorical tokens (10 hues). Tuple literal so the element type
+ * stays narrow (`'chart-blue' | 'chart-orange' | ...`) rather than
+ * widening to `ChartPaletteToken`; downstream consumers like
+ * `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` rely on
+ * the narrow element type to enforce 1:1 coverage at compile time. */
+export const CATEGORICAL_PALETTE_TOKENS = [
+ 'chart-blue',
+ 'chart-orange',
+ 'chart-red',
+ 'chart-cyan',
+ 'chart-green',
+ 'chart-pink',
+ 'chart-purple',
+ 'chart-light-blue',
+ 'chart-brown',
+ 'chart-gray',
+] as const;
+
+/** Semantic tokens (success / warning / error). Tuple literal for the
+ * same narrow-element-type reason as the categorical list above. */
+export const SEMANTIC_PALETTE_TOKENS = [
'chart-success',
'chart-warning',
'chart-error',
] as const;
+export const CHART_PALETTE_TOKENS = [
+ ...CATEGORICAL_PALETTE_TOKENS,
+ ...SEMANTIC_PALETTE_TOKENS,
+] as const;
+
export type ChartPaletteToken = (typeof CHART_PALETTE_TOKENS)[number];
-/** Categorical tokens (chart-1 .. chart-10). */
-export const CATEGORICAL_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice(
- 0,
- 10,
-) as readonly ChartPaletteToken[];
+/** Numeric tokens (`chart-1` .. `chart-10`) shipped in #2265. */
+type LegacyChartPaletteTokenKey =
+ | 'chart-1'
+ | 'chart-2'
+ | 'chart-3'
+ | 'chart-4'
+ | 'chart-5'
+ | 'chart-6'
+ | 'chart-7'
+ | 'chart-8'
+ | 'chart-9'
+ | 'chart-10';
-/** Semantic tokens (success / warning / error). */
-export const SEMANTIC_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice(
- 10,
-) as readonly ChartPaletteToken[];
+/**
+ * Legacy numeric tokens (`chart-1` .. `chart-10`) shipped in the initial
+ * release of the number-tile color picker (#2265). Renamed to hue-named
+ * tokens here to make stored configs and the external API schema
+ * self-documenting; mapped at parse time so saved tiles keep working.
+ *
+ * Mapping preserves the HyperDX slot ordering from #2265 (slot 1 was
+ * brand green, slot 2 was blue, and so on through the Observable 10
+ * palette).
+ *
+ * ⚠️ ClickStack caveat: pre-rename ClickStack resolved `--color-chart-1`
+ * to brand blue, not brand green, so a ClickStack tile saved with the
+ * old "Color 1" will visually shift after migration. The trade-off
+ * (and why we don't theme-branch this map) is documented in
+ * `agent_docs/data_viz_colors.md` and the changeset for #2362.
+ *
+ * Keyed by the narrow `LegacyChartPaletteTokenKey` union (rather than
+ * `string`) so a typo in a legacy slot at edit time becomes a compile
+ * error.
+ */
+export const LEGACY_CHART_PALETTE_TOKEN_MAP = {
+ 'chart-1': 'chart-green',
+ 'chart-2': 'chart-blue',
+ 'chart-3': 'chart-orange',
+ 'chart-4': 'chart-red',
+ 'chart-5': 'chart-cyan',
+ 'chart-6': 'chart-pink',
+ 'chart-7': 'chart-purple',
+ 'chart-8': 'chart-light-blue',
+ 'chart-9': 'chart-brown',
+ 'chart-10': 'chart-gray',
+} as const satisfies Record;
+
+export type LegacyChartPaletteToken =
+ keyof typeof LEGACY_CHART_PALETTE_TOKEN_MAP;
/** Type guard for runtime validation of an unknown token string. */
export function isChartPaletteToken(
@@ -845,7 +903,114 @@ export function isChartPaletteToken(
);
}
-/** Zod schema that accepts only the curated palette tokens above. */
+function isLegacyChartPaletteToken(
+ value: unknown,
+): value is LegacyChartPaletteToken {
+ return (
+ typeof value === 'string' &&
+ Object.prototype.hasOwnProperty.call(LEGACY_CHART_PALETTE_TOKEN_MAP, value)
+ );
+}
+
+/**
+ * Resolve any string to a canonical `ChartPaletteToken`, accepting both
+ * current hue-named tokens and legacy numeric tokens (`chart-1` ..
+ * `chart-10`) from #2265. Returns `undefined` for anything else.
+ *
+ * Use this at every render-time consumption point (dashboard tile
+ * renderers like `DBNumberChart`, the color picker's `safeValue` guard
+ * in `ColorSwatchInput`, etc.). The app's normalizer
+ * (`normalizeDashboardTileColors` in `packages/app/src/dashboard.ts`)
+ * heals dashboards both on fetch (`useDashboards` /
+ * `fetchLocalDashboards`) and on write (`useUpdateDashboard` /
+ * `useCreateDashboard`), so the DB-side data converges on next save
+ * and JSON imports / preset constructions don't trip the strict
+ * `ChartPaletteTokenSchema`. Render-time consumers still call this
+ * helper as defense in depth for tiles built in memory between fetch
+ * and save (`ChartEditor` form state, unit-test fixtures).
+ */
+export function resolveChartPaletteToken(
+ value: unknown,
+): ChartPaletteToken | undefined {
+ if (typeof value !== 'string') return undefined;
+ if (isLegacyChartPaletteToken(value)) {
+ return LEGACY_CHART_PALETTE_TOKEN_MAP[value];
+ }
+ return isChartPaletteToken(value) ? value : undefined;
+}
+
+/**
+ * Walk a parsed-but-not-yet-typed dashboard payload and yield each
+ * `tiles[i].config.color` that holds a string, asking `onColor` what
+ * the new value should be. The walker is the single shared
+ * implementation behind:
+ * - the React app's fetch- / write-time normalizer
+ * (`normalizeDashboardTileColors` in `packages/app/src/dashboard.ts`)
+ * - the JSON-import pre-validation pass
+ * (`normalizeRawDashboardTileColors`, same file)
+ * - the API dashboards route middleware
+ * (`migrateLegacyDashboardTileColors` in
+ * `packages/api/src/routers/api/dashboards.ts`)
+ * - the dashboard-provisioner task
+ * (`packages/api/src/tasks/provisionDashboards/index.ts`)
+ *
+ * `onColor` receives the current string and returns one of:
+ * - `undefined` → strip the `color` field from that tile's config.
+ * - a string identical to `current` → leave the tile untouched
+ * (preserves referential identity so React reconciliation stays
+ * cheap).
+ * - a different string → rewrite `config.color` to the new value.
+ *
+ * Returns the (possibly new) `input` reference. When nothing changed,
+ * the same `input` is returned so `===` callers can short-circuit.
+ * Inputs that aren't an object, or whose `tiles` isn't an array, are
+ * returned unchanged.
+ */
+export function walkRawDashboardTileColors(
+ input: unknown,
+ onColor: (current: string) => string | undefined,
+): unknown {
+ if (!input || typeof input !== 'object') return input;
+ const root = input as { tiles?: unknown };
+ const tiles = root.tiles;
+ if (!Array.isArray(tiles)) return input;
+ let changed = false;
+ const nextTiles = (tiles as unknown[]).map(tile => {
+ if (!tile || typeof tile !== 'object') return tile;
+ const t = tile as { config?: unknown };
+ const config = t.config;
+ if (!config || typeof config !== 'object') return tile;
+ const c = config as { color?: unknown };
+ const current = c.color;
+ if (typeof current !== 'string') return tile;
+ const next = onColor(current);
+ if (next === current) return tile;
+ changed = true;
+ if (next === undefined) {
+ const { color: _drop, ...rest } = c;
+ return { ...t, config: rest };
+ }
+ return { ...t, config: { ...c, color: next } };
+ });
+ return changed ? { ...root, tiles: nextTiles } : input;
+}
+
+/**
+ * Strict Zod schema for the curated palette tokens. Intentionally
+ * does NOT accept legacy numeric tokens (`chart-1` .. `chart-10`)
+ * from #2265 — wrapping the enum in `z.preprocess` would force the
+ * schema's input type to `unknown`, which breaks downstream `z.infer`
+ * consumers (e.g. `validateRequest` in the API handlers infers
+ * `req.body` as `unknown` for any field reached through this schema).
+ *
+ * Legacy data is healed at load time instead: see
+ * `normalizeDashboardTileColors` in `packages/app/src/dashboard.ts`,
+ * which walks `tiles[i].config.color` and replaces any legacy token
+ * with its hue-named equivalent via `resolveChartPaletteToken`.
+ * Render-time consumers also call `resolveChartPaletteToken` as
+ * belt-and-suspenders against any data path that bypasses the
+ * fetch-time normalizer.
+ */
export const ChartPaletteTokenSchema = z.enum(CHART_PALETTE_TOKENS);
// When making changes here, consider if they need to be made to the external API