From 8a9f9ad363e6ee53fc93cba8f722a8b9f1260c1e Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 13:32:23 +0100 Subject: [PATCH 01/17] refactor(theme): rename chart palette tokens to hue names + unify across themes Renames the user-facing chart palette tokens from numeric (chart-1..10) to hue-named (chart-blue, chart-orange, ...) so stored chart configs and the upcoming external API surface are self-documenting. Also unifies the categorical palette across HyperDX and ClickStack onto Observable 10; brand identity moves to the new --color-chart-info semantic token plus the existing per-brand --color-chart-success. Stored configs from #2265 keep working: a Zod preprocess on ChartPaletteTokenSchema transparently maps chart-1..10 to their hue-named equivalents at parse time, preserving the HyperDX slot ordering (slot 1 = brand green = chart-green, etc.). User-visible: the color picker now shows readable swatch labels (Blue, Orange, Red, ...) instead of "Color 1", "Color 2", ... --- .changeset/rename-chart-palette-tokens.md | 10 + agent_docs/data_viz_colors.md | 360 ++++++++++-------- packages/app/src/__tests__/ChartUtils.test.ts | 6 +- packages/app/src/__tests__/utils.test.ts | 45 ++- .../ChartEditor/__tests__/utils.test.ts | 4 +- .../components/ColorSwatchInput.stories.tsx | 2 +- .../src/components/ColorSwatchInput.test.tsx | 28 +- .../app/src/components/ColorSwatchInput.tsx | 20 +- packages/app/src/components/DBRowTable.tsx | 11 +- .../ChartDisplaySettingsDrawer.test.tsx | 8 +- .../app/src/theme/ChartColors.stories.tsx | 21 +- .../app/src/theme/semanticColorsGrouped.ts | 20 +- .../src/theme/themes/clickstack/_tokens.scss | 81 ++-- .../app/src/theme/themes/hyperdx/_tokens.scss | 76 ++-- packages/app/src/utils.ts | 239 ++++++------ .../common-utils/src/__tests__/guards.test.ts | 73 +++- packages/common-utils/src/types.ts | 75 +++- 17 files changed, 640 insertions(+), 439 deletions(-) create mode 100644 .changeset/rename-chart-palette-tokens.md diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md new file mode 100644 index 0000000000..9a9b38fb6a --- /dev/null +++ b/.changeset/rename-chart-palette-tokens.md @@ -0,0 +1,10 @@ +--- +"@hyperdx/common-utils": minor +"@hyperdx/app": minor +--- + +refactor(theme): rename chart palette tokens from chart-1..10 to hue-named (chart-blue, chart-orange, ...) and unify the categorical palette across HyperDX and ClickStack + +Stored configs from the initial color picker (#2265) keep working — a Zod `preprocess` on `ChartPaletteTokenSchema` transparently maps legacy `chart-1` .. `chart-10` to their hue-named equivalents at parse time, preserving the HyperDX slot ordering (slot 1 = brand green, slot 2 = blue, etc.). + +Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and the `getChartColorInfo()` helper resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to Observable blue. The categorical palette is now Observable 10, identical across themes — picking `chart-blue` always renders blue. diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 72438f54c1..0fcec79e0b 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -9,11 +9,11 @@ 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 hues)** | Multi-series line/bar/area/pie charts | CSS vars `--color-chart-{hue}` + `CATEGORICAL_HEX_BY_TOKEN` | `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**: @@ -23,92 +23,135 @@ consumption patterns: 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-N` for heatmap density; don't reuse the heatmap - arrays for series colors. + Don't reuse `--color-chart-{hue}` for heatmap density; don't reuse the + heatmap arrays for series colors. ## Where the colors live -### Categorical series palette (`--color-chart-1` through `--color-chart-10`) - -| 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 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: - -- **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). - -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. - -### Semantic chart colors +### Categorical palette (`--color-chart-{hue}`) + +The categorical palette is **unified across themes** — Observable 10 +([d3 schemeObservable10](https://observablehq.com/@d3/color-schemes)), +identical on HyperDX and ClickStack: + +| Token | Hex | +| ------------------ | --------- | +| `chart-blue` | `#4269d0` | +| `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` | + +The CSS vars are defined in both +`packages/app/src/theme/themes/hyperdx/_tokens.scss` and +`packages/app/src/theme/themes/clickstack/_tokens.scss`, duplicated inside +each `[data-mantine-color-scheme]` selector. That duplication is +intentional and called out in the files — CSS specificity requires the +chart vars to live inside each scheme block. + +Brand identity for charts is carried by the **semantic** tokens +(`--color-chart-success`, `-info`) and by non-chart UI chrome (Mantine +accent, sidebar gradient, Click UI globals), not by which hue happens to +appear at categorical slot 0. + +### Semantic chart colors (`--color-chart-{success|warning|error|info}`) ```text ---color-chart-success # success / OK / ingested / info-level fills +--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 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`). - -### JavaScript fallback (`packages/app/src/utils.ts`) +Defined in both `_tokens.scss` files. **Per-brand**: HyperDX uses brand +green for `success` and `info` (`#00c28a`); ClickStack uses Observable +green (`#3ca951`) for `success` and Observable blue (`#4269d0`) for +`info`. Warning and error are the same across themes. -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: +### Single source of truth in TS (`packages/app/src/utils.ts`) ```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 +CATEGORICAL_HEX_BY_TOKEN # { 'chart-blue': '#4269d0', ... } — file-private +SEMANTIC_CHART_PALETTE # { hyperdx: {...}, clickstack: {...} } — file-private +COLORS # ordered hex array, derived from CATEGORICAL_PALETTE_TOKENS ``` -`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]` equals `CATEGORICAL_HEX_BY_TOKEN[CATEGORICAL_PALETTE_TOKENS[i]]`. +`COLORS` is exported as the SSR fallback for `getColorFromCSSVariable(i)`. + +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, with log-level override applied | -| `semanticKeyedColor(key, index)` | Same, but driven by `key` (e.g. series name) | -| `getChartColorSuccess()` | `var(--color-chart-success)` resolved to a hex string| -| `getChartColorWarning()` | `var(--color-chart-warning)` resolved | -| `getChartColorError()` | `var(--color-chart-error)` resolved | -| `getChartColor*Highlight()` | Hover/selected variants | -| `logLevelColor(key)` | Maps `'error' \| 'warn' \| 'info'` → semantic color | -| `getLogLevelColorOrder()` | Stable ordering for log-level series | +| 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)` 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. +- `getColorFromCSSVariable(index)` reads + `--color-chart-{CATEGORICAL_PALETTE_TOKENS[index]}` (e.g. + `--color-chart-blue` for `index=0`) from `documentElement` via + `getComputedStyle`. SSR / missing-var fallback is `COLORS[index]`. +- `getColorFromCSSToken(token)` reads `--color-{token}` directly. SSR + fallback resolves categorical tokens through `CATEGORICAL_HEX_BY_TOKEN` + and semantic tokens through the active theme's `SEMANTIC_CHART_PALETTE`. +- `getSemanticChartColor(varName, key)` uses `detectActiveTheme()` (checks + for the `theme-clickstack` class on ``) to pick the correct + per-brand 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. + hydration mismatch window is tiny because charts render after data + fetch on the client. + +### 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 transparently via a Zod `preprocess` +on `ChartPaletteTokenSchema` that maps the legacy values: + +```text +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 +``` + +The mapping preserves the HyperDX slot ordering from #2265, so HyperDX +users see no visual change. ClickStack users (whose stored `chart-1` +historically rendered as Observable blue at runtime) now see Observable +green for that token after migration — by design, since the slot now +means a hue, not "brand primary." Use `chart-info` semantic if you need +the brand-primary appearance. + +`LEGACY_CHART_PALETTE_TOKEN_MAP` lives in +`packages/common-utils/src/types.ts` next to the enum. ### Heatmap palette (component-local) @@ -135,44 +178,21 @@ 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. + 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: - -```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', -]; -``` - +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 @@ -199,6 +219,23 @@ const series = data.map((s, i) => ({ 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: + +```tsx +import { getColorFromCSSToken } from '@/utils'; + +Always blue +``` + +Or directly in CSS: + +```tsx + +``` + ### Status pill / delta indicator ```tsx @@ -208,20 +245,28 @@ 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 +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 ```tsx @@ -250,30 +295,32 @@ 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. ## Per-theme considerations -### HyperDX (green-first) +The categorical palette is identical on both themes — Observable 10. The +only place themes differ is the semantic chart layer. -- 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. +### HyperDX -### ClickStack (blue-first) +- `--color-chart-success` and `--color-chart-info` use brand green + (`#00c28a`). +- Single-series charts that pick a "neutral" / info-level color via + `getChartColorInfo()` render brand green. +- Multi-series charts start at Observable blue (slot 0) and proceed + through the canonical palette — brand identity is preserved via the + Mantine green accent, sidebar gradient, and semantic chart tokens. + +### ClickStack - 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. +- `--color-chart-success` uses Observable green (`#3ca951`). +- `--color-chart-info` uses Observable blue (`#4269d0`), so info-level + logs and `getChartColorInfo()` render blue. ### Both themes @@ -285,7 +332,7 @@ first color in the palette" is at line ~444. ## Adding new entries -### A new categorical slot (slot 11+) +### 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 @@ -294,11 +341,12 @@ 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 the same `--color-chart-N` var to - **all four** SCSS blocks (HyperDX dark, HyperDX light, ClickStack - dark, ClickStack light), append the hex to `CHART_PALETTE` / - `CLICKSTACK_CHART_PALETTE`, append to `COLORS`, append a label to - `COLOR_LABELS` in `ChartColors.stories.tsx`. +3. If you really must extend: add `--color-chart-{newhue}` to **all four** + SCSS blocks (HyperDX dark, HyperDX light, ClickStack dark, ClickStack + light), 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`) @@ -306,12 +354,18 @@ descending order of preference: ClickStack uses Observable variants). 2. Add `--color-chart-pending` and (if needed) `--color-chart-pending-highlight` to **all four** SCSS blocks. -3. Add a hex to `CHART_PALETTE` and `CLICKSTACK_CHART_PALETTE`. -4. Add a `getChartColorPending()` reader in `utils.ts` that calls - `getSemanticChartColor('--color-chart-pending', hyperdxHex, clickstackHex)`. -5. Update `ChartColors.stories.tsx` `SEMANTIC_CHART_COLORS` so the new +3. Add `pending` (and optionally `pendingHighlight`) to + `SEMANTIC_CHART_PALETTE.hyperdx` and `.clickstack` 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. -6. If it should override based on a label / status string, extend +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 @@ -329,46 +383,52 @@ pattern (`useMantineColorScheme` + ternary). ## Anti-patterns ```tsx - // hex string in a chart - // CSS color keyword - // raw Mantine palette for a status - -import { CHART_PALETTE } from '@/utils'; // CHART_PALETTE is not exported + // hex string in a chart + // CSS color keyword + // raw Mantine palette for a status + // palette object is module-private ``` Why each is wrong: -- **Hex / keyword**: bypasses theme switching. ClickStack users will see - HyperDX colors, dark/light won't react. +- **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**: they're module-private. The exported - surface is the reader functions and `COLORS` (the SSR fallback array, - not for direct use in components). +- **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 HyperDX ↔ ClickStack theme — series colors change as - expected (slot 1 swaps green ↔ blue). +- [ ] Toggled HyperDX ↔ ClickStack theme — semantic colors (success / info) + change as expected; categorical palette stays identical. - [ ] 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 hex, changed it in **all four** places - (HyperDX SCSS, ClickStack SCSS, `CHART_PALETTE` / - `CLICKSTACK_CHART_PALETTE`, `COLORS`). +- [ ] If you added or changed a categorical hex, changed it in **all three** + places (HyperDX SCSS, ClickStack SCSS, `CATEGORICAL_HEX_BY_TOKEN` in + `utils.ts`). +- [ ] If you added or changed a semantic hex, changed it in **all three** + places (the relevant theme's SCSS, `SEMANTIC_CHART_PALETTE.{theme}` in + `utils.ts`). - [ ] Storybook `Design Tokens / Chart Colors` still renders correctly. ## File reference summary -| What | Where | -| ------------------------------------------ | --------------------------------------------------------------------------- | -| HyperDX chart vars (dark + light) | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | -| ClickStack chart vars (dark + light) | `packages/app/src/theme/themes/clickstack/_tokens.scss` | -| JS palettes + reader functions | `packages/app/src/utils.ts` | -| Multi-series wiring (`setLineColors` etc.) | `packages/app/src/ChartUtils.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`) | +| What | Where | +| ------------------------------------------ | ---------------------------------------------------------------------------------- | +| HyperDX chart vars (dark + light) | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | +| ClickStack chart vars (dark + light) | `packages/app/src/theme/themes/clickstack/_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`) | diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index e2c1081e8d..851ad7ab50 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -11,7 +11,7 @@ import { formatResponseForPieChart, formatResponseForTimeChart, } from '@/ChartUtils'; -import { COLORS, getChartColorError } from '@/utils'; +import { COLORS, getChartColorError, getChartColorInfo } from '@/utils'; describe('ChartUtils', () => { describe('formatResponseForTimeChart', () => { @@ -306,7 +306,7 @@ describe('ChartUtils', () => { expect(actual.lineData).toEqual([ { - color: COLORS[0], + color: getChartColorInfo(), dataKey: 'info', currentPeriodKey: 'info', previousPeriodKey: 'info (previous)', @@ -315,7 +315,7 @@ describe('ChartUtils', () => { isDashed: false, }, { - color: COLORS[0], + color: getChartColorInfo(), dataKey: 'debug', currentPeriodKey: 'debug', previousPeriodKey: 'debug (previous)', diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index bedea9c851..f632060a4f 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1,4 +1,8 @@ -import { NumericUnit, TSource } from '@hyperdx/common-utils/dist/types'; +import { + ChartPaletteTokenSchema, + NumericUnit, + TSource, +} from '@hyperdx/common-utils/dist/types'; import { SortingState } from '@tanstack/react-table'; import { act, renderHook } from '@testing-library/react'; @@ -1213,10 +1217,10 @@ describe('getColorFromCSSToken', () => { it('returns the CSS variable value when getComputedStyle provides one', () => { jest.spyOn(global, 'getComputedStyle').mockReturnValue({ getPropertyValue: (name: string) => - name === '--color-chart-1' ? '#custom-green' : '', + name === '--color-chart-blue' ? '#custom-blue' : '', } as unknown as CSSStyleDeclaration); - expect(getColorFromCSSToken('chart-1')).toBe('#custom-green'); + expect(getColorFromCSSToken('chart-blue')).toBe('#custom-blue'); }); it('returns the CSS variable value for semantic tokens when provided', () => { @@ -1228,12 +1232,12 @@ describe('getColorFromCSSToken', () => { expect(getColorFromCSSToken('chart-success')).toBe('#theme-green'); }); - it('falls back to COLORS[0] for chart-1 when the CSS variable is empty', () => { + it('falls back to COLORS[0] for chart-blue when the CSS variable is empty', () => { jest.spyOn(global, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '', } as unknown as CSSStyleDeclaration); - expect(getColorFromCSSToken('chart-1')).toBe(COLORS[0]); + expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); }); it('falls back to the SSR palette when getComputedStyle throws', () => { @@ -1241,18 +1245,19 @@ describe('getColorFromCSSToken', () => { throw new Error('getComputedStyle unavailable'); }); - // Semantic tokens fall back to their designated palette colors. - // These hex values are the CHART_PALETTE constants used in paletteTokenSSRFallback. + // Semantic tokens fall back to the active theme's SEMANTIC_CHART_PALETTE + // entry (defaults to HyperDX in jsdom because the document has no + // theme-clickstack class). expect(getColorFromCSSToken('chart-success')).toBe('#00c28a'); expect(getColorFromCSSToken('chart-warning')).toBe('#efb118'); expect(getColorFromCSSToken('chart-error')).toBe('#ff725c'); - // Categorical token falls back to the COLORS array by index. - expect(getColorFromCSSToken('chart-1')).toBe(COLORS[0]); - expect(getColorFromCSSToken('chart-10')).toBe(COLORS[9]); + // Categorical tokens fall back to their canonical hex in the unified + // Observable 10 palette. + expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); + expect(getColorFromCSSToken('chart-gray')).toBe(COLORS[9]); }); - it('falls back to the SSR palette for all categorical indices (chart-1 through chart-10)', () => { - // Verify every categorical token resolves to its expected COLORS entry. + it('falls back to the SSR palette for every categorical token in CATEGORICAL_PALETTE_TOKENS', () => { // The throw-branch exercises the same paletteTokenSSRFallback code as the // SSR branch (window === undefined), which jsdom prevents from being // simulated via Object.defineProperty. @@ -1260,8 +1265,18 @@ describe('getColorFromCSSToken', () => { throw new Error('not available'); }); - for (let i = 1; i <= 10; i++) { - expect(getColorFromCSSToken(`chart-${i}` as any)).toBe(COLORS[i - 1]); - } + utils.CATEGORICAL_PALETTE_TOKENS.forEach((token, i) => { + expect(getColorFromCSSToken(token)).toBe(COLORS[i]); + }); + }); + + it('migrates legacy numeric tokens (chart-1..10) to hue-named tokens via the Zod schema', () => { + // Tokens like `chart-1` are not part of the new ChartPaletteToken type + // but stored configs from #2265 still contain them; Zod's preprocess + // remaps them to their HyperDX-slot-order hue equivalents. + const parsed = ChartPaletteTokenSchema.parse('chart-1'); + expect(parsed).toBe('chart-green'); + expect(ChartPaletteTokenSchema.parse('chart-2')).toBe('chart-blue'); + expect(ChartPaletteTokenSchema.parse('chart-10')).toBe('chart-gray'); }); }); diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 1e45cfbb34..9167387d83 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -1352,12 +1352,12 @@ describe('color round-trip (sql/promql Number tile)', () => { displayType: DisplayType.Number, sqlTemplate: 'SELECT count() FROM logs', connection: 'conn-1', - color: 'chart-2', + color: 'chart-orange', series: [], }; const result = convertFormStateToChartConfig(form, dateRange, undefined); expect(result).toBeDefined(); - expect((result as any).color).toBe('chart-2'); + expect((result as any).color).toBe('chart-orange'); }); it('preserves color through convertFormStateToSavedChartConfig for promql Number tile', () => { diff --git a/packages/app/src/components/ColorSwatchInput.stories.tsx b/packages/app/src/components/ColorSwatchInput.stories.tsx index 98c0abb5f5..08a2e1f1a9 100644 --- a/packages/app/src/components/ColorSwatchInput.stories.tsx +++ b/packages/app/src/components/ColorSwatchInput.stories.tsx @@ -26,7 +26,7 @@ export const Default = () => { export const Selected = () => { const [value, setValue] = React.useState( - 'chart-1', + 'chart-blue', ); return ; }; diff --git a/packages/app/src/components/ColorSwatchInput.test.tsx b/packages/app/src/components/ColorSwatchInput.test.tsx index e56cf43748..fe45b7bf8a 100644 --- a/packages/app/src/components/ColorSwatchInput.test.tsx +++ b/packages/app/src/components/ColorSwatchInput.test.tsx @@ -25,7 +25,7 @@ describe('ColorSwatchInput', () => { await user.click(screen.getByTestId('color-swatch-input-trigger')); expect( - await screen.findByTestId('color-swatch-option-chart-1'), + await screen.findByTestId('color-swatch-option-chart-blue'), ).toBeInTheDocument(); expect( screen.getByTestId('color-swatch-option-chart-success'), @@ -72,12 +72,14 @@ describe('ColorSwatchInput', () => { it('marks only the selected swatch as pressed', async () => { const user = userEvent.setup(); - renderWithMantine(); + renderWithMantine(); await user.click(screen.getByTestId('color-swatch-input-trigger')); - const selected = await screen.findByTestId('color-swatch-option-chart-2'); - const other = await screen.findByTestId('color-swatch-option-chart-3'); + const selected = await screen.findByTestId( + 'color-swatch-option-chart-orange', + ); + const other = await screen.findByTestId('color-swatch-option-chart-red'); expect(selected).toHaveAttribute('aria-pressed', 'true'); expect(other).toHaveAttribute('aria-pressed', 'false'); }); @@ -85,7 +87,9 @@ describe('ColorSwatchInput', () => { it('clears the selection via the Clear button', async () => { const onChange = jest.fn(); const user = userEvent.setup(); - renderWithMantine(); + renderWithMantine( + , + ); await user.click(screen.getByTestId('color-swatch-input-trigger')); await user.click(await screen.findByTestId('color-swatch-input-clear')); @@ -101,7 +105,7 @@ describe('ColorSwatchInput', () => { await user.click(screen.getByTestId('color-swatch-input-trigger')); expect( - await screen.findByTestId('color-swatch-option-chart-1'), + await screen.findByTestId('color-swatch-option-chart-blue'), ).toBeInTheDocument(); expect( screen.queryByTestId('color-swatch-input-clear'), @@ -144,7 +148,7 @@ describe('ColorSwatchInput', () => { await user.click(trigger); expect( - screen.queryByTestId('color-swatch-option-chart-1'), + screen.queryByTestId('color-swatch-option-chart-blue'), ).not.toBeInTheDocument(); }); @@ -153,11 +157,13 @@ describe('ColorSwatchInput', () => { renderWithMantine(); await user.click(screen.getByTestId('color-swatch-input-trigger')); - await user.click(await screen.findByTestId('color-swatch-option-chart-3')); + await user.click( + await screen.findByTestId('color-swatch-option-chart-red'), + ); await waitFor(() => { expect( - screen.queryByTestId('color-swatch-option-chart-3'), + screen.queryByTestId('color-swatch-option-chart-red'), ).not.toBeInTheDocument(); }); }); @@ -180,11 +186,11 @@ describe('ColorSwatchInput', () => { fireEvent.click(trigger); const firstSwatch = await screen.findByTestId( - 'color-swatch-option-chart-1', + 'color-swatch-option-chart-blue', ); firstSwatch.focus(); fireEvent.click(firstSwatch); - expect(onChange).toHaveBeenCalledWith('chart-1'); + expect(onChange).toHaveBeenCalledWith('chart-blue'); }); }); diff --git a/packages/app/src/components/ColorSwatchInput.tsx b/packages/app/src/components/ColorSwatchInput.tsx index 2ce27a9ecf..fbaf0f3e81 100644 --- a/packages/app/src/components/ColorSwatchInput.tsx +++ b/packages/app/src/components/ColorSwatchInput.tsx @@ -27,16 +27,16 @@ import classes from './ColorSwatchInput.module.scss'; const Z_INDEX = 9999; const TOKEN_LABELS: Record = { - 'chart-1': 'Color 1', - 'chart-2': 'Color 2', - 'chart-3': 'Color 3', - 'chart-4': 'Color 4', - 'chart-5': 'Color 5', - 'chart-6': 'Color 6', - 'chart-7': 'Color 7', - 'chart-8': 'Color 8', - 'chart-9': 'Color 9', - 'chart-10': 'Color 10', + 'chart-blue': 'Blue', + 'chart-orange': 'Orange', + 'chart-red': 'Red', + 'chart-cyan': 'Cyan', + 'chart-green': 'Green', + 'chart-pink': 'Pink', + 'chart-purple': 'Purple', + 'chart-light-blue': 'Light Blue', + 'chart-brown': 'Brown', + 'chart-gray': 'Gray', 'chart-success': 'Success', 'chart-warning': 'Warning', 'chart-error': 'Error', diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 9d2c8c31e6..a125e225b8 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -93,7 +93,7 @@ import { import { FormatTime } from '@/useFormatTime'; import { useUserPreferences } from '@/useUserPreferences'; import { - COLORS, + getChartColorInfo, getLogLevelClass, logLevelColor, useLocalStorage, @@ -269,16 +269,9 @@ const PatternTrendChart = ({ isAnimationActive={false} dataKey="count" stackId="a" - fill={color || COLORS[0]} + fill={color || getChartColorInfo()} maxBarSize={24} /> - {/* */} } /> diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index db430e1ded..5484e7cb93 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -69,12 +69,14 @@ describe('ChartDisplaySettingsDrawer', () => { await user.click(screen.getByTestId('color-swatch-input-trigger')); // Pick a categorical token. - const swatch = await screen.findByTestId('color-swatch-option-chart-1'); + const swatch = await screen.findByTestId( + 'color-swatch-option-chart-blue', + ); await user.click(swatch); // Verify the trigger shows the selected token. const trigger = screen.getByTestId('color-swatch-input-trigger'); - expect(trigger).toHaveTextContent(/color 1/i); + expect(trigger).toHaveTextContent(/blue/i); // Apply the settings. await user.click(screen.getByRole('button', { name: /apply/i })); @@ -82,7 +84,7 @@ describe('ChartDisplaySettingsDrawer', () => { expect(onChange).toHaveBeenCalledTimes(1); // react-hook-form passes (values, event) to the onSubmit handler; // check the values argument directly. - expect(onChange.mock.calls[0][0]).toMatchObject({ color: 'chart-1' }); + expect(onChange.mock.calls[0][0]).toMatchObject({ color: 'chart-blue' }); }); it('calls onChange with a semantic token when Apply is clicked', async () => { diff --git a/packages/app/src/theme/ChartColors.stories.tsx b/packages/app/src/theme/ChartColors.stories.tsx index 56e17a2568..2eafb58c38 100644 --- a/packages/app/src/theme/ChartColors.stories.tsx +++ b/packages/app/src/theme/ChartColors.stories.tsx @@ -1,19 +1,20 @@ import React from 'react'; import { + CATEGORICAL_PALETTE_TOKENS, COLORS, getChartColorError, getChartColorSuccess, getChartColorWarning, } from '@/utils'; -// Labels for chart colors - brand green first, then Observable palette +// Pretty labels for the picker UI, in the same order as CATEGORICAL_PALETTE_TOKENS. const COLOR_LABELS = [ - 'Green (Brand)', 'Blue', 'Orange', 'Red', 'Cyan', + 'Green', 'Pink', 'Purple', 'Light Blue', @@ -21,11 +22,12 @@ const COLOR_LABELS = [ 'Gray', ]; -// 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; the token +// (e.g. `chart-blue`) is the CSS-var slug too. +const CHART_COLORS = CATEGORICAL_PALETTE_TOKENS.map((token, i) => ({ + name: `color-${token}`, + hex: COLORS[i], + label: COLOR_LABELS[i] ?? token, })); const SEMANTIC_CHART_COLORS = [ @@ -352,7 +354,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..6e20011b10 100644 --- a/packages/app/src/theme/semanticColorsGrouped.ts +++ b/packages/app/src/theme/semanticColorsGrouped.ts @@ -57,16 +57,16 @@ 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', diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 0c77f5498c..d62a2f27f0 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -173,27 +173,33 @@ --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. + * Chart Categorical Palette - Observable 10, unified across themes. + * Hue-named tokens resolve to the same hex on both HyperDX and + * ClickStack so a user's color choice means the same thing + * regardless of brand. Brand identity carries through the semantic + * --color-chart-success / -info tokens below and the Click UI accents. + * + * NOTE: Duplicated across dark and light mode sections by design. + * CSS specificity requires them to live inside each + * [data-mantine-color-scheme] selector; a shared .theme-clickstack + * section would have lower specificity and could be overridden. */ - --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 */ + --color-chart-blue: #4269d0; + --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; + + /* Chart Semantic Colors - per-brand (ClickStack blue for info, Observable green for success) */ + --color-chart-success: #3ca951; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #4269d0; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; @@ -379,22 +385,25 @@ --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 Categorical Palette - Observable 10, unified across themes. + Duplicated from the dark block: CSS specificity requires the chart + vars to live inside each [data-mantine-color-scheme] selector. */ + --color-chart-blue: #4269d0; + --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; + + /* Chart Semantic Colors - per-brand (ClickStack blue for info, Observable green for success) */ + --color-chart-success: #3ca951; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #4269d0; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 1503bbc4ae..a73eddf7a0 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -92,24 +92,29 @@ --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 Categorical Palette - Observable 10, unified across themes. + Hue-named tokens (chart-blue, chart-orange, ...) keep stored chart + configs self-documenting and resolve to the same hex on both + HyperDX and ClickStack so a user's color choice means the same + thing regardless of brand. Brand identity carries through the + semantic --color-chart-success / -info tokens below, plus the + Mantine accent and chrome elsewhere. */ + --color-chart-blue: #4269d0; + --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; + + /* Chart Semantic Colors - per-brand (HyperDX brand green for success / info) */ + --color-chart-success: #00c28a; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #00c28a; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; @@ -206,22 +211,25 @@ --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 Categorical Palette - Observable 10, unified across themes. + Duplicated from the dark block: CSS specificity requires the chart + vars to live inside each [data-mantine-color-scheme] selector. */ + --color-chart-blue: #4269d0; + --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; + + /* Chart Semantic Colors - per-brand (HyperDX brand green for success / info) */ + --color-chart-success: #00c28a; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #00c28a; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index e80d6a25ee..e084b2d2cb 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,71 @@ 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; +/** + * Chart Categorical Palette - Observable 10, unified across themes. + * https://observablehq.com/@d3/color-schemes + * + * Both HyperDX and ClickStack resolve `--color-chart-{hue}` to the + * same hex; per-brand identity carries through the semantic chart + * tokens below and through 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 both `packages/app/src/theme/themes/{hyperdx,clickstack}/_tokens.scss`. + */ +const CATEGORICAL_HEX_BY_TOKEN = { + 'chart-blue': '#4269d0', + '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; -// 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', +/** + * 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 each theme's `_tokens.scss`. + */ +const SEMANTIC_CHART_PALETTE = { + hyperdx: { + success: '#00c28a', + warning: '#efb118', + error: '#ff725c', + info: '#00c28a', + successHighlight: '#80d9b3', + warningHighlight: '#f5c94d', + errorHighlight: '#ffa090', + }, + clickstack: { + success: '#3ca951', + warning: '#efb118', + error: '#ff725c', + info: '#4269d0', + successHighlight: '#80d9b3', + warningHighlight: '#f5c94d', + errorHighlight: '#ffa090', + }, } 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 -]; +type SemanticChartColorKey = keyof typeof SEMANTIC_CHART_PALETTE.hyperdx; + +/** + * Ordered hex array for positional series assignment. + * `COLORS[i]` === `CATEGORICAL_HEX_BY_TOKEN[CATEGORICAL_PALETTE_TOKENS[i]]`. + * SSR fallback only; in the browser, `getColorFromCSSVariable()` reads + * from the matching `--color-chart-{hue}` CSS variable. + */ +export const COLORS: string[] = CATEGORICAL_PALETTE_TOKENS.map( + token => + CATEGORICAL_HEX_BY_TOKEN[token as keyof typeof CATEGORICAL_HEX_BY_TOKEN], +); /** * Palette token types and runtime guards live in common-utils so the @@ -448,30 +460,26 @@ 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). + * Reads a categorical chart color from a CSS variable by index. * - * 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. + * Index N maps to the Nth token in `CATEGORICAL_PALETTE_TOKENS`, which + * in turn maps to `--color-chart-{hue}` in each theme's `_tokens.scss`. + * Falls back to `COLORS[N]` (the same hex the var resolves to, since + * the categorical palette is now unified across themes) when running + * server-side or when `getComputedStyle` fails. */ function getColorFromCSSVariable(index: number): string { - const colorArrayLength = COLORS.length; + const i = index % CATEGORICAL_PALETTE_TOKENS.length; + const token = CATEGORICAL_PALETTE_TOKENS[i]; if (typeof window === 'undefined') { - // SSR: fallback to default colors (HyperDX palette) - return COLORS[index % colorArrayLength]; + return COLORS[i]; } try { - const cssVarName = `--color-chart-${(index % colorArrayLength) + 1}`; - // Read from documentElement - CSS variables cascade from theme classes + const cssVarName = `--color-${token}`; 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; } @@ -479,21 +487,19 @@ function getColorFromCSSVariable(index: number): string { // Fallback if getComputedStyle fails } - // Fallback to default colors - return COLORS[index % colorArrayLength]; + 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). + * CSS variable (`--color-{token}`). * - * 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. + * Falls back to the unified categorical palette (for hue tokens) or + * the active theme's semantic palette (for `chart-success` / `-warning` + * / `-error`) when running server-side or when `getComputedStyle` fails. * * @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 { @@ -519,16 +525,21 @@ export function getColorFromCSSToken(token: ChartPaletteToken): string { function paletteTokenSSRFallback(token: ChartPaletteToken): string { switch (token) { case 'chart-success': - return CHART_PALETTE.green; case 'chart-warning': - return CHART_PALETTE.orange; - case 'chart-error': - return CHART_PALETTE.red; - default: { - // Categorical token: chart-N where N is 1..10 - const index = Number(token.slice('chart-'.length)) - 1; - return COLORS[index] ?? COLORS[0]; + case 'chart-error': { + const theme = detectActiveTheme(); + const key = token.slice('chart-'.length) as + | 'success' + | 'warning' + | 'error'; + return SEMANTIC_CHART_PALETTE[theme][key]; } + default: + return ( + CATEGORICAL_HEX_BY_TOKEN[ + token as keyof typeof CATEGORICAL_HEX_BY_TOKEN + ] ?? COLORS[0] + ); } } @@ -546,21 +557,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 +582,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'); +} + +/** Primary brand color used for info-level logs and similar + * "neutral / default" series. HyperDX brand green, ClickStack blue. */ +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 +638,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 +651,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 +668,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..9f5a749e43 100644 --- a/packages/common-utils/src/__tests__/guards.test.ts +++ b/packages/common-utils/src/__tests__/guards.test.ts @@ -3,7 +3,11 @@ import { isBuilderSavedChartConfig, isRawSqlSavedChartConfig, } from '@/guards'; -import { DisplayType, isChartPaletteToken } from '@/types'; +import { + ChartPaletteTokenSchema, + DisplayType, + isChartPaletteToken, +} from '@/types'; describe('isRawSqlSavedChartConfig', () => { it('returns true when configType is "sql"', () => { @@ -170,9 +174,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 +198,14 @@ describe('isChartPaletteToken', () => { expect(isChartPaletteToken('chart-error')).toBe(true); }); + it('returns false for legacy numeric tokens (handled by Zod preprocess, not the guard)', () => { + // The guard checks the current ChartPaletteToken enum strictly; the + // ChartPaletteTokenSchema preprocess is responsible for migrating + // legacy chart-1..10 values from stored configs. + 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 +226,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 +242,36 @@ describe('isChartPaletteToken', () => { expect(isChartPaletteToken('chart-neutral')).toBe(false); }); }); + +describe('ChartPaletteTokenSchema legacy migration', () => { + it('migrates each chart-1..10 to its HyperDX-slot-order hue equivalent', () => { + const expected = [ + 'chart-green', // 1 (HyperDX brand green) + 'chart-blue', // 2 + 'chart-orange', // 3 + 'chart-red', // 4 + 'chart-cyan', // 5 + 'chart-pink', // 6 + 'chart-purple', // 7 + 'chart-light-blue', // 8 + 'chart-brown', // 9 + 'chart-gray', // 10 + ]; + expected.forEach((target, i) => { + expect(ChartPaletteTokenSchema.parse(`chart-${i + 1}`)).toBe(target); + }); + }); + + it('passes through valid hue tokens unchanged', () => { + expect(ChartPaletteTokenSchema.parse('chart-blue')).toBe('chart-blue'); + expect(ChartPaletteTokenSchema.parse('chart-success')).toBe( + 'chart-success', + ); + }); + + it('rejects unknown strings even after preprocess', () => { + expect(() => ChartPaletteTokenSchema.parse('chart-magenta')).toThrow(); + expect(() => ChartPaletteTokenSchema.parse('chart-11')).toThrow(); + expect(() => ChartPaletteTokenSchema.parse('#ff0000')).toThrow(); + }); +}); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 4f7eab990b..6f5a01300e 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -795,8 +795,8 @@ 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} (per-brand semantic) * * Storing tokens (not hex) lets user choices reflow correctly across * themes and color modes; see notes/repo-conventions/hyperdx/tile-styling.md. @@ -807,16 +807,16 @@ export function isOnClickDashboardById( * `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', + 'chart-blue', + 'chart-orange', + 'chart-red', + 'chart-cyan', + 'chart-green', + 'chart-pink', + 'chart-purple', + 'chart-light-blue', + 'chart-brown', + 'chart-gray', 'chart-success', 'chart-warning', 'chart-error', @@ -824,7 +824,7 @@ export const CHART_PALETTE_TOKENS = [ export type ChartPaletteToken = (typeof CHART_PALETTE_TOKENS)[number]; -/** Categorical tokens (chart-1 .. chart-10). */ +/** Categorical tokens (10 hues). */ export const CATEGORICAL_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice( 0, 10, @@ -835,6 +835,32 @@ 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). + */ +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( value: unknown, @@ -845,8 +871,27 @@ export function isChartPaletteToken( ); } -/** Zod schema that accepts only the curated palette tokens above. */ -export const ChartPaletteTokenSchema = z.enum(CHART_PALETTE_TOKENS); +function isLegacyChartPaletteToken( + value: unknown, +): value is LegacyChartPaletteToken { + return ( + typeof value === 'string' && + Object.prototype.hasOwnProperty.call(LEGACY_CHART_PALETTE_TOKEN_MAP, value) + ); +} + +/** + * Zod schema for the curated palette tokens, transparently migrating + * legacy numeric tokens (`chart-1` .. `chart-10`) to their hue-named + * equivalents at parse time so stored configs from #2265 keep working. + */ +export const ChartPaletteTokenSchema = z.preprocess( + value => + isLegacyChartPaletteToken(value) + ? LEGACY_CHART_PALETTE_TOKEN_MAP[value] + : value, + z.enum(CHART_PALETTE_TOKENS), +); // When making changes here, consider if they need to be made to the external API // schema as well (packages/api/src/utils/zod.ts). From 6e2d26f4bbcf303f47547ed7330f128c09c3bce9 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 14:07:03 +0100 Subject: [PATCH 02/17] fix(theme): resolve legacy chart-1..10 at render time + cover semantic gradient defs Two regressions reported against the hue-named palette refactor: 1. Stored Number-tile colors from #2265 (chart-1..chart-10) were silently dropped on dashboard load. `useDashboards` raw-casts the API response (`hdxServer('dashboards').json()`), so `ChartPaletteTokenSchema`'s `preprocess` never runs and `isChartPaletteToken('chart-1')` resolves `tileColor` to undefined until the user re-saves the tile. Add a new `resolveChartPaletteToken(value)` helper in common-utils that accepts both current hue-named tokens and legacy chart-1..10 values. Use it in DBNumberChart (last-mile resolution before `getColorFromCSSToken`) and in ColorSwatchInput's `safeValue` guard so the picker also shows the migrated swatch when reopening a tile saved with a legacy token. 2. After unifying `COLORS` to the Observable 10 palette, HyperDX info-level fills in HDXMultiSeriesTimeChart referenced `url(#time-chart-lin-grad--00c28a)`, but the gradient block iterates `COLORS`, which no longer contains the HyperDX brand-green `#00c28a` returned by `getChartColorInfo()`. Info-level series rendered with no area fill. Build the gradient defs from the union of `COLORS` and every `lineData[i].color` actually in use, so any semantic color (`info`/`success`/`warning`/`error`) returned by the per-brand helpers is guaranteed to have a matching gradient def. Regression tests cover both bugs (resolveChartPaletteToken in guards.test.ts; DBNumberChart legacy-token render path; ColorSwatchInput legacy-token displays migrated swatch). --- .changeset/rename-chart-palette-tokens.md | 2 +- packages/app/src/HDXMultiSeriesTimeChart.tsx | 12 +++++- .../src/components/ColorSwatchInput.test.tsx | 23 +++++++++++ .../app/src/components/ColorSwatchInput.tsx | 10 +++-- packages/app/src/components/DBNumberChart.tsx | 18 +++++---- .../__tests__/DBNumberChart.test.tsx | 19 +++++++++ packages/app/src/utils.ts | 1 + .../common-utils/src/__tests__/guards.test.ts | 40 +++++++++++++++++++ packages/common-utils/src/types.ts | 21 ++++++++++ 9 files changed, 134 insertions(+), 12 deletions(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 9a9b38fb6a..5b820f0026 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -5,6 +5,6 @@ refactor(theme): rename chart palette tokens from chart-1..10 to hue-named (chart-blue, chart-orange, ...) and unify the categorical palette across HyperDX and ClickStack -Stored configs from the initial color picker (#2265) keep working — a Zod `preprocess` on `ChartPaletteTokenSchema` transparently maps legacy `chart-1` .. `chart-10` to their hue-named equivalents at parse time, preserving the HyperDX slot ordering (slot 1 = brand green, slot 2 = blue, etc.). +Stored configs from the initial color picker (#2265) keep working two ways. `ChartPaletteTokenSchema` runs a Zod `preprocess` that transparently maps legacy `chart-1` .. `chart-10` to their hue-named equivalents at parse time. Render-time consumers (`DBNumberChart`, `ColorSwatchInput`) additionally call a new `resolveChartPaletteToken` helper so legacy tokens still resolve even when the surrounding payload bypasses the schema (e.g. `useDashboards` raw-casts the API response). Both paths preserve the HyperDX slot ordering (slot 1 = brand green, slot 2 = blue, etc.). Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and the `getChartColorInfo()` helper resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to Observable blue. The categorical palette is now Observable 10, identical across themes — picking `chart-blue` always renders blue. diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 1ef8c840a5..f2425e4052 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -675,7 +675,17 @@ export const MemoChart = memo(function MemoChart({ }} > - {COLORS.map(c => { + {/* Gradient defs cover every hex that any fill may reference. + `COLORS` (the unified categorical palette) is included up-front + as a baseline; semantic colors returned by the + `getChartColor{Info,Success,Warning,Error}` helpers can also + appear in `lineData[].color` (e.g. info-level log series + resolve to the brand-green `--color-chart-info` on HyperDX, + which is not part of `COLORS`). Union them here so the + referenced `url(#time-chart-lin-grad-…)` always exists. */} + {Array.from( + new Set([...COLORS, ...lineData.map(ld => ld.color)]), + ).map(c => { return ( { } }); + it('migrates a legacy chart-1..10 value to the matching hue swatch', async () => { + // Regression: dashboards saved through #2265 store `chart-1`..`chart-10`. + // The fetch path raw-casts API responses so ChartPaletteTokenSchema's + // legacy preprocess never runs. The picker must still show the + // migrated selection so users don't see "no color" when reopening a + // tile's Display Settings drawer. + const user = userEvent.setup(); + renderWithMantine( + , + ); + + const trigger = screen.getByTestId('color-swatch-input-trigger'); + expect(trigger).toHaveTextContent(/green/i); + + await user.click(trigger); + expect( + await screen.findByTestId('color-swatch-option-chart-green'), + ).toHaveAttribute('aria-pressed', 'true'); + }); + it('respects disabled and does not open the popover', async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/packages/app/src/components/ColorSwatchInput.tsx b/packages/app/src/components/ColorSwatchInput.tsx index fbaf0f3e81..9cb8a383b9 100644 --- a/packages/app/src/components/ColorSwatchInput.tsx +++ b/packages/app/src/components/ColorSwatchInput.tsx @@ -18,7 +18,7 @@ import { CATEGORICAL_PALETTE_TOKENS, ChartPaletteToken, getColorFromCSSToken, - isChartPaletteToken, + resolveChartPaletteToken, SEMANTIC_PALETTE_TOKENS, } from '@/utils'; @@ -73,8 +73,12 @@ export const ColorSwatchInput = ({ }: ColorSwatchInputProps) => { const [opened, setOpened] = React.useState(false); - // Guard against legacy values that are not in the current palette. - const safeValue = value && isChartPaletteToken(value) ? value : undefined; + // Accept both current hue-named tokens and legacy `chart-1`..`chart-10` + // values from #2265 so an existing tile's color shows correctly the first + // time its drawer is opened (the dashboard fetch path doesn't run the + // Zod schema, so the legacy preprocess wouldn't have fired). Truly + // unknown values still fall through to "no selection". + const safeValue = resolveChartPaletteToken(value); const handleChange = (next?: ChartPaletteToken) => { onChange?.(next); diff --git a/packages/app/src/components/DBNumberChart.tsx b/packages/app/src/components/DBNumberChart.tsx index c17b508911..a22e7ab47b 100644 --- a/packages/app/src/components/DBNumberChart.tsx +++ b/packages/app/src/components/DBNumberChart.tsx @@ -9,7 +9,7 @@ import { } from '@hyperdx/common-utils/dist/guards'; import { ChartConfigWithDateRange, - isChartPaletteToken, + resolveChartPaletteToken, } from '@hyperdx/common-utils/dist/types'; import { Flex, Text } from '@mantine/core'; @@ -93,12 +93,16 @@ export default function DBNumberChart({ }); // Tile-level color override resolved at render time so token choices - // reflow correctly across light / dark / IDE themes. Unknown tokens - // (legacy strings, schema gaps) fall back to the default text color. - const tileColor = - config.color && isChartPaletteToken(config.color) - ? getColorFromCSSToken(config.color) - : undefined; + // reflow correctly across light / dark / IDE themes. `resolveChartPaletteToken` + // accepts both current hue-named tokens and legacy `chart-1`..`chart-10` + // values from stored configs (the dashboard fetch path raw-casts the API + // response and bypasses `ChartPaletteTokenSchema`'s preprocess, so this + // call is the last guarantee that legacy tokens still render correctly). + // Unknown strings fall back to the default text color. + const resolvedColorToken = resolveChartPaletteToken(config.color); + const tileColor = resolvedColorToken + ? getColorFromCSSToken(resolvedColorToken) + : undefined; const toolbarItemsMemo = useMemo(() => { const allToolbarItems = []; diff --git a/packages/app/src/components/__tests__/DBNumberChart.test.tsx b/packages/app/src/components/__tests__/DBNumberChart.test.tsx index 8bdc539217..96147cf7a5 100644 --- a/packages/app/src/components/__tests__/DBNumberChart.test.tsx +++ b/packages/app/src/components/__tests__/DBNumberChart.test.tsx @@ -348,5 +348,24 @@ describe('DBNumberChart', () => { expect(mockGetColorFromCSSToken).not.toHaveBeenCalled(); expect(screen.getByText('1234')).toBeInTheDocument(); }); + + it('migrates legacy chart-1..10 tokens to their hue-named equivalent at render time', () => { + // Regression: useDashboards raw-casts the API response so saved configs + // from #2265 (`color: 'chart-1'`, etc.) never go through + // ChartPaletteTokenSchema. The renderer must apply the same legacy + // migration via resolveChartPaletteToken before the CSS-token lookup, + // otherwise tileColor silently resolves to undefined until the user + // re-saves the dashboard. + const config = { + ...baseTestConfig, + color: 'chart-1' as any, + }; + + renderWithMantine(); + + expect(mockGetColorFromCSSToken).toHaveBeenCalledWith('chart-green'); + const textEl = screen.getByText('1234'); + expect(textEl).toHaveStyle({ color: 'rgb(0, 255, 0)' }); + }); }); }); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index e084b2d2cb..c06816aafe 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -434,6 +434,7 @@ export { CATEGORICAL_PALETTE_TOKENS, CHART_PALETTE_TOKENS, isChartPaletteToken, + resolveChartPaletteToken, SEMANTIC_PALETTE_TOKENS, } from '@hyperdx/common-utils/dist/types'; export type { ChartPaletteToken }; diff --git a/packages/common-utils/src/__tests__/guards.test.ts b/packages/common-utils/src/__tests__/guards.test.ts index 9f5a749e43..78078b5a89 100644 --- a/packages/common-utils/src/__tests__/guards.test.ts +++ b/packages/common-utils/src/__tests__/guards.test.ts @@ -7,6 +7,7 @@ import { ChartPaletteTokenSchema, DisplayType, isChartPaletteToken, + resolveChartPaletteToken, } from '@/types'; describe('isRawSqlSavedChartConfig', () => { @@ -275,3 +276,42 @@ describe('ChartPaletteTokenSchema legacy migration', () => { 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(); + }); +}); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 6f5a01300e..d6df116432 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -880,6 +880,27 @@ function isLegacyChartPaletteToken( ); } +/** + * 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, the color picker's `safeValue` guard, etc.). Stored + * dashboard payloads are not always Zod-parsed end-to-end — e.g. + * `useDashboards` raw-casts the API response — so the schema's + * preprocess can't be the only thing guaranteeing legacy migration. + */ +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; +} + /** * Zod schema for the curated palette tokens, transparently migrating * legacy numeric tokens (`chart-1` .. `chart-10`) to their hue-named From 64f857f96dab46841c8c1a9d25c17f048fa3259b Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 14:23:38 +0100 Subject: [PATCH 03/17] fix(theme): drop ChartPaletteTokenSchema preprocess + heal legacy colors at fetch time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix wrapped `ChartPaletteTokenSchema` in `z.preprocess` to migrate legacy `chart-1`..`chart-10` tokens at parse time. That forces the schema's `z.input` type to `unknown`, which `validateRequest` (from `zod-express-middleware`) then propagates all the way up to `req.body.tiles[i].config.color`. The mismatch with `createDashboard`'s parameter (typed via `z.infer` = output = `ChartPaletteToken | undefined`) broke `packages/api/src/routers/api/dashboards.ts` with TS2345. Strict input/output equality is more important than burying a migration in the schema. So: - Revert `ChartPaletteTokenSchema` to plain `z.enum(CHART_PALETTE_TOKENS)`. `z.input` == `z.output` again; the api compiles. - Move the legacy migration to a fetch-time normalizer in `packages/app/src/dashboard.ts` (`normalizeDashboardTileColors`). `useDashboards` and `fetchLocalDashboards` now run every tile through it, so the in-memory copy is healed immediately and the persisted copy is updated the next time the dashboard is saved for any reason. Unknown strings pass through untouched (no silent data loss); hue tokens pass through unchanged with tile identity preserved. - Keep the render-time `resolveChartPaletteToken` calls in `DBNumberChart` and `ColorSwatchInput` as defense in depth for any tile constructed in memory (presets, unit tests, etc.) that bypasses the fetch path. Tests: - New `fetchLocalDashboards` cases in `dashboard.test.ts` cover the fetch-time normalizer end-to-end (chart-1..10 migrated, hue tokens pass through, unknown strings preserved, tiles without `color` untouched). - `ChartPaletteTokenSchema` test suite tightened: it now asserts that the schema rejects `chart-1`..`chart-10` (with a comment pointing consumers at the app-side normalizer and `resolveChartPaletteToken`). - `resolveChartPaletteToken` tests in `guards.test.ts` are unchanged and still cover the legacy → hue mapping. Docs: - `agent_docs/data_viz_colors.md` rewritten to explain the two-layer migration (fetch + render) and the rationale for not using `z.preprocess`. - Changeset updated. --- .changeset/rename-chart-palette-tokens.md | 2 +- agent_docs/data_viz_colors.md | 40 ++++++--- packages/app/src/__tests__/dashboard.test.ts | 83 +++++++++++++++++++ packages/app/src/__tests__/utils.test.ts | 18 ++-- .../src/components/ColorSwatchInput.test.tsx | 11 +-- .../app/src/components/ColorSwatchInput.tsx | 8 +- packages/app/src/components/DBNumberChart.tsx | 12 +-- .../__tests__/DBNumberChart.test.tsx | 13 +-- packages/app/src/dashboard.ts | 35 +++++++- .../common-utils/src/__tests__/guards.test.ts | 48 +++++------ packages/common-utils/src/types.ts | 25 +++--- 11 files changed, 218 insertions(+), 77 deletions(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 5b820f0026..912c389ae5 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -5,6 +5,6 @@ refactor(theme): rename chart palette tokens from chart-1..10 to hue-named (chart-blue, chart-orange, ...) and unify the categorical palette across HyperDX and ClickStack -Stored configs from the initial color picker (#2265) keep working two ways. `ChartPaletteTokenSchema` runs a Zod `preprocess` that transparently maps legacy `chart-1` .. `chart-10` to their hue-named equivalents at parse time. Render-time consumers (`DBNumberChart`, `ColorSwatchInput`) additionally call a new `resolveChartPaletteToken` helper so legacy tokens still resolve even when the surrounding payload bypasses the schema (e.g. `useDashboards` raw-casts the API response). Both paths preserve the HyperDX slot ordering (slot 1 = brand green, slot 2 = blue, etc.). +Stored configs from the initial color picker (#2265) keep working. `ChartPaletteTokenSchema` stays strict (a plain `z.enum`, so its `z.input` matches `z.output` — wrapping it in `z.preprocess` would poison `validateRequest`'s `req.body` inference all the way up to `Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` happens in two complementary places: a fetch-time `normalizeDashboardTileColors` (in `packages/app/src/dashboard.ts`) heals the data once it lands in the client cache, and a new render-time `resolveChartPaletteToken` helper (in `common-utils/types.ts`, re-exported from `packages/app/src/utils.ts`) acts as belt-and-suspenders inside `DBNumberChart` and `ColorSwatchInput`. Both paths preserve the HyperDX slot ordering from #2265 (slot 1 = brand green, slot 2 = blue, etc.). Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and the `getChartColorInfo()` helper resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to Observable blue. The categorical palette is now Observable 10, identical across themes — picking `chart-blue` always renders blue. diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 0fcec79e0b..0983896a0b 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -132,8 +132,7 @@ 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 transparently via a Zod `preprocess` -on `ChartPaletteTokenSchema` that maps the legacy values: +Existing stored configs keep working. The mapping: ```text chart-1 -> chart-green (was HyperDX brand green at slot 1) @@ -143,14 +142,35 @@ chart-3 -> chart-orange chart-10 -> chart-gray ``` -The mapping preserves the HyperDX slot ordering from #2265, so HyperDX -users see no visual change. ClickStack users (whose stored `chart-1` -historically rendered as Observable blue at runtime) now see Observable -green for that token after migration — by design, since the slot now -means a hue, not "brand primary." Use `chart-info` semantic if you need -the brand-primary appearance. - -`LEGACY_CHART_PALETTE_TOKEN_MAP` lives in +It preserves the HyperDX slot ordering from #2265, so HyperDX users see +no visual change. ClickStack users (whose stored `chart-1` historically +rendered as Observable blue at runtime) now see Observable green for +that token after migration — by design, since the slot now means a hue, +not "brand primary." 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** — `normalizeDashboardTileColors` in + `packages/app/src/dashboard.ts` walks every tile in the response + from `useDashboards` / `fetchLocalDashboards` and rewrites any + legacy `config.color` to its hue-named equivalent via + `resolveChartPaletteToken`. This heals the in-memory copy + immediately; the persisted copy is updated the next time the user + saves the dashboard for any reason (color or otherwise). +2. **Render-time** — `DBNumberChart` and `ColorSwatchInput` also call + `resolveChartPaletteToken` as belt-and-suspenders for any code path + that bypasses the fetch normalizer (e.g. a tile constructed in + memory from a `Tile` literal). + +`LEGACY_CHART_PALETTE_TOKEN_MAP` and `resolveChartPaletteToken` live in `packages/common-utils/src/types.ts` next to the enum. ### Heatmap palette (component-local) diff --git a/packages/app/src/__tests__/dashboard.test.ts b/packages/app/src/__tests__/dashboard.test.ts index 5e5ba38773..80ffb6c5c4 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -35,6 +35,89 @@ describe('fetchLocalDashboards', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards)); expect(fetchLocalDashboards()).toHaveLength(2); }); + + describe('legacy tile color migration (fetch-time normalizer)', () => { + // Stored configs from #2265 (the initial number-tile color picker) + // contain `color: 'chart-1'..'chart-10'`. The rename refactor swapped + // those numeric tokens for hue-named ones and kept `ChartPaletteToken + // Schema` strict, so legacy values must be healed at load time. + const storeDashboardWithTileColor = (color: unknown) => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { + id: 'a', + name: 'A', + tiles: [ + { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color }, + }, + ], + tags: [], + }, + ]), + ); + }; + + it('migrates chart-1..10 to their HyperDX-slot-order hue equivalents', () => { + storeDashboardWithTileColor('chart-1'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-green', + }); + + storeDashboardWithTileColor('chart-2'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-blue', + }); + + storeDashboardWithTileColor('chart-10'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-gray', + }); + }); + + it('passes through hue-named tokens unchanged', () => { + storeDashboardWithTileColor('chart-orange'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-orange', + }); + }); + + it('leaves unknown strings alone (no silent data loss)', () => { + // A forward-compat or hand-edited value should survive the + // normalizer untouched; render-time consumers can decide to + // ignore it. The alternative — erasing the value — would be + // worse than leaving it. + storeDashboardWithTileColor('chart-future-magenta'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-future-magenta', + }); + }); + + it('does not touch tiles whose config has no color field', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { + id: 'a', + name: 'A', + tiles: [ + { id: 't1', x: 0, y: 0, w: 4, h: 4, config: { displayType: 1 } }, + ], + tags: [], + }, + ]), + ); + expect(fetchLocalDashboards()[0].tiles[0].config).toEqual({ + displayType: 1, + }); + }); + }); }); describe('getLocalDashboardTags', () => { diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index f632060a4f..ecce8ef5c4 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1270,13 +1270,15 @@ describe('getColorFromCSSToken', () => { }); }); - it('migrates legacy numeric tokens (chart-1..10) to hue-named tokens via the Zod schema', () => { - // Tokens like `chart-1` are not part of the new ChartPaletteToken type - // but stored configs from #2265 still contain them; Zod's preprocess - // remaps them to their HyperDX-slot-order hue equivalents. - const parsed = ChartPaletteTokenSchema.parse('chart-1'); - expect(parsed).toBe('chart-green'); - expect(ChartPaletteTokenSchema.parse('chart-2')).toBe('chart-blue'); - expect(ChartPaletteTokenSchema.parse('chart-10')).toBe('chart-gray'); + it('schema rejects legacy chart-1..10; render-time consumers rely on resolveChartPaletteToken instead', () => { + // The schema is deliberately strict (no `z.preprocess`) so that + // its `z.input` type matches its `z.output` type — otherwise + // `validateRequest` in the API would infer `req.body.tiles[i] + // .config.color` as `unknown`. Legacy migration for stored + // configs from #2265 happens at fetch time via + // `normalizeDashboardTileColors` and at render time via + // `resolveChartPaletteToken`. + expect(() => ChartPaletteTokenSchema.parse('chart-1')).toThrow(); + expect(() => ChartPaletteTokenSchema.parse('chart-10')).toThrow(); }); }); diff --git a/packages/app/src/components/ColorSwatchInput.test.tsx b/packages/app/src/components/ColorSwatchInput.test.tsx index c3acec6c77..224cf16c01 100644 --- a/packages/app/src/components/ColorSwatchInput.test.tsx +++ b/packages/app/src/components/ColorSwatchInput.test.tsx @@ -140,11 +140,12 @@ describe('ColorSwatchInput', () => { }); it('migrates a legacy chart-1..10 value to the matching hue swatch', async () => { - // Regression: dashboards saved through #2265 store `chart-1`..`chart-10`. - // The fetch path raw-casts API responses so ChartPaletteTokenSchema's - // legacy preprocess never runs. The picker must still show the - // migrated selection so users don't see "no color" when reopening a - // tile's Display Settings drawer. + // Defense in depth: `normalizeDashboardTileColors` heals stored + // legacy tokens at fetch time, so the picker should usually only + // see hue-named values. But preset / in-memory tiles or any code + // path that bypasses the fetch normalizer can still pass a + // legacy `chart-1` here, so the picker also resolves it before + // matching against the swatches. const user = userEvent.setup(); renderWithMantine( { diff --git a/packages/app/src/components/DBNumberChart.tsx b/packages/app/src/components/DBNumberChart.tsx index a22e7ab47b..f2bc1869bc 100644 --- a/packages/app/src/components/DBNumberChart.tsx +++ b/packages/app/src/components/DBNumberChart.tsx @@ -93,11 +93,13 @@ export default function DBNumberChart({ }); // Tile-level color override resolved at render time so token choices - // reflow correctly across light / dark / IDE themes. `resolveChartPaletteToken` - // accepts both current hue-named tokens and legacy `chart-1`..`chart-10` - // values from stored configs (the dashboard fetch path raw-casts the API - // response and bypasses `ChartPaletteTokenSchema`'s preprocess, so this - // call is the last guarantee that legacy tokens still render correctly). + // reflow correctly across light / dark / IDE themes. + // `resolveChartPaletteToken` accepts both current hue-named tokens and + // legacy `chart-1`..`chart-10` values from stored configs. The fetch + // path (`normalizeDashboardTileColors`) already heals stored data, so + // in practice this resolver only ever sees the migrated hue tokens — + // but we keep the call as defense in depth against any tile that gets + // constructed in memory without going through the fetch normalizer. // Unknown strings fall back to the default text color. const resolvedColorToken = resolveChartPaletteToken(config.color); const tileColor = resolvedColorToken diff --git a/packages/app/src/components/__tests__/DBNumberChart.test.tsx b/packages/app/src/components/__tests__/DBNumberChart.test.tsx index 96147cf7a5..a1d2370d51 100644 --- a/packages/app/src/components/__tests__/DBNumberChart.test.tsx +++ b/packages/app/src/components/__tests__/DBNumberChart.test.tsx @@ -350,12 +350,13 @@ describe('DBNumberChart', () => { }); it('migrates legacy chart-1..10 tokens to their hue-named equivalent at render time', () => { - // Regression: useDashboards raw-casts the API response so saved configs - // from #2265 (`color: 'chart-1'`, etc.) never go through - // ChartPaletteTokenSchema. The renderer must apply the same legacy - // migration via resolveChartPaletteToken before the CSS-token lookup, - // otherwise tileColor silently resolves to undefined until the user - // re-saves the dashboard. + // Defense in depth: in practice `normalizeDashboardTileColors` in + // `packages/app/src/dashboard.ts` heals legacy tokens at fetch + // time, so renderers should always see hue-named values. But any + // tile constructed in memory (e.g. from a preset or a unit test) + // can still carry a legacy `chart-1`, so the renderer also + // resolves through `resolveChartPaletteToken` before the + // CSS-token lookup. const config = { ...baseTestConfig, color: 'chart-1' as any, diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 0a4851c4ce..376950f031 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -4,6 +4,7 @@ import { DashboardContainer, DashboardFilter, Filter, + resolveChartPaletteToken, SavedChartConfig, SearchConditionLanguage, } from '@hyperdx/common-utils/dist/types'; @@ -46,11 +47,39 @@ export type Dashboard = { const localDashboards = createEntityStore('hdx-local-dashboards'); +/** + * Heal legacy `chart-1`..`chart-10` colors stored on tiles by #2265 + * into their hue-named equivalents. Applied at fetch time so every + * downstream consumer (renderers, the color picker, save mutations) + * sees the canonical hue tokens that `ChartPaletteTokenSchema` + * accepts. Unknown strings are left untouched so we don't accidentally + * erase forward-compat values; hue tokens already match the resolver + * and pass through unchanged. The returned tile array preserves + * identity where nothing changed so React reconciliation stays cheap. + */ +function normalizeDashboardTileColors(dashboard: Dashboard): Dashboard { + let changed = false; + const tiles = dashboard.tiles.map(tile => { + const config = tile.config as { color?: unknown }; + const current = config?.color; + if (typeof current !== 'string') return tile; + const resolved = resolveChartPaletteToken(current); + if (resolved === undefined || resolved === current) return tile; + changed = true; + return { + ...tile, + config: { ...tile.config, color: resolved } as Tile['config'], + }; + }); + return changed ? { ...dashboard, tiles } : dashboard; +} + async function fetchDashboards(): Promise { if (IS_LOCAL_MODE) { - return localDashboards.getAll(); + return localDashboards.getAll().map(normalizeDashboardTileColors); } - return hdxServer('dashboards').json(); + const dashboards = await hdxServer('dashboards').json(); + return dashboards.map(normalizeDashboardTileColors); } export function useUpdateDashboard() { @@ -196,7 +225,7 @@ export function useDashboard({ } export function fetchLocalDashboards(): Dashboard[] { - return localDashboards.getAll(); + return localDashboards.getAll().map(normalizeDashboardTileColors); } export function getLocalDashboardTags(): string[] { diff --git a/packages/common-utils/src/__tests__/guards.test.ts b/packages/common-utils/src/__tests__/guards.test.ts index 78078b5a89..e083547ada 100644 --- a/packages/common-utils/src/__tests__/guards.test.ts +++ b/packages/common-utils/src/__tests__/guards.test.ts @@ -199,10 +199,11 @@ describe('isChartPaletteToken', () => { expect(isChartPaletteToken('chart-error')).toBe(true); }); - it('returns false for legacy numeric tokens (handled by Zod preprocess, not the guard)', () => { - // The guard checks the current ChartPaletteToken enum strictly; the - // ChartPaletteTokenSchema preprocess is responsible for migrating - // legacy chart-1..10 values from stored configs. + 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); }); @@ -244,33 +245,30 @@ describe('isChartPaletteToken', () => { }); }); -describe('ChartPaletteTokenSchema legacy migration', () => { - it('migrates each chart-1..10 to its HyperDX-slot-order hue equivalent', () => { - const expected = [ - 'chart-green', // 1 (HyperDX brand green) - 'chart-blue', // 2 - 'chart-orange', // 3 - 'chart-red', // 4 - 'chart-cyan', // 5 - 'chart-pink', // 6 - 'chart-purple', // 7 - 'chart-light-blue', // 8 - 'chart-brown', // 9 - 'chart-gray', // 10 - ]; - expected.forEach((target, i) => { - expect(ChartPaletteTokenSchema.parse(`chart-${i + 1}`)).toBe(target); - }); - }); - - it('passes through valid hue tokens unchanged', () => { +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 unknown strings even after preprocess', () => { + 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(); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index d6df116432..4fadcda905 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -902,17 +902,22 @@ export function resolveChartPaletteToken( } /** - * Zod schema for the curated palette tokens, transparently migrating - * legacy numeric tokens (`chart-1` .. `chart-10`) to their hue-named - * equivalents at parse time so stored configs from #2265 keep working. + * 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.preprocess( - value => - isLegacyChartPaletteToken(value) - ? LEGACY_CHART_PALETTE_TOKEN_MAP[value] - : value, - z.enum(CHART_PALETTE_TOKENS), -); +export const ChartPaletteTokenSchema = z.enum(CHART_PALETTE_TOKENS); // When making changes here, consider if they need to be made to the external API // schema as well (packages/api/src/utils/zod.ts). From e103ad3f2b3ec27d552348d41fa26995a04dd595 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 18:13:00 +0000 Subject: [PATCH 04/17] chore(theme): drop dead isChartPaletteToken re-export + minor #2362 cleanups Three follow-ups on Elizabet's chart palette rename PR after the deep-review pass: - Drop the orphan `isChartPaletteToken` re-export in packages/app/src/utils.ts. Knip flagged it; the two app-side consumers (ColorSwatchInput, DBNumberChart) both moved to `resolveChartPaletteToken` earlier in the PR, so nothing in app/ imports it anymore. - HDXMultiSeriesTimeChart: filter `lineData[].color` to defined strings before unioning into the gradient `Set`. The downstream `c.replace('#', '')` would throw on `undefined` if any future caller bypassed `setLineColors`. - common-utils: update the JSDoc on `resolveChartPaletteToken` to describe the real remaining bypass paths (in-memory tile construction, ChartEditor form state, unit-test fixtures). The previous comment cited `useDashboards` as the bypass, but the new fetch-time `normalizeDashboardTileColors` heals `useDashboards` / `fetchLocalDashboards` payloads, so the rationale needed a refresh. Validated locally: lint, tsc, knip clean; common-utils unit (1145/1145) and app unit across the touched surface area (627/627) pass. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 7 ++++++- packages/app/src/utils.ts | 1 - packages/common-utils/src/types.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index f2425e4052..4b80da7a31 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -684,7 +684,12 @@ export const MemoChart = memo(function MemoChart({ which is not part of `COLORS`). Union them here so the referenced `url(#time-chart-lin-grad-…)` always exists. */} {Array.from( - new Set([...COLORS, ...lineData.map(ld => ld.color)]), + new Set([ + ...COLORS, + ...lineData + .map(ld => ld.color) + .filter((c): c is string => typeof c === 'string'), + ]), ).map(c => { return ( Date: Thu, 28 May 2026 20:28:31 +0100 Subject: [PATCH 05/17] refactor(data viz): unify categorical color palette and streamline color retrieval Updated the data visualization color documentation to clarify the unified categorical color palette based on Observable 10, with `chart-blue` adjusted to match the brand link color. The JavaScript source of truth for categorical hues is now emphasized, eliminating unnecessary CSS variable reads. The `getColorFromCSSToken` and `getColorFromCSSVariable` functions have been refactored to directly return values from `CATEGORICAL_HEX_BY_TOKEN`, improving performance by avoiding layout reads. Tests have been updated to reflect these changes, ensuring consistent behavior across themes. --- agent_docs/data_viz_colors.md | 410 +++++++++--------- packages/app/src/__tests__/utils.test.ts | 46 +- .../src/theme/themes/clickstack/_tokens.scss | 44 +- .../app/src/theme/themes/hyperdx/_tokens.scss | 39 +- packages/app/src/utils.ts | 100 +++-- 5 files changed, 345 insertions(+), 294 deletions(-) diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 0983896a0b..e0fd0f797f 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -1,42 +1,45 @@ # Data Visualization Colors -> Single source of truth for chart and visualization colors in HyperDX. -> Read this before adding, changing, or hard-coding a color in any chart, -> sparkline, heatmap, legend, status pill, or other data display. +> Single source of truth for chart and visualization colors in HyperDX. 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: +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 | CSS vars `--color-chart-{hue}` + `CATEGORICAL_HEX_BY_TOKEN` | `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` | +| 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. +- **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. + 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** — Observable 10 -([d3 schemeObservable10](https://observablehq.com/@d3/color-schemes)), -identical on HyperDX and ClickStack: +The categorical palette is **unified across themes** — based on Observable 10 +([d3 schemeObservable10](https://observablehq.com/@d3/color-schemes)), 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` | `#4269d0` | +| `chart-blue` | `#437eef` | | `chart-orange` | `#efb118` | | `chart-red` | `#ff725c` | | `chart-cyan` | `#6cc5b0` | @@ -47,17 +50,34 @@ identical on HyperDX and ClickStack: | `chart-brown` | `#9c6b4e` | | `chart-gray` | `#9498a0` | -The CSS vars are defined in both +**Source of truth for categorical hues lives in JS.** The matching +`--color-chart-{hue}` CSS vars in both `packages/app/src/theme/themes/hyperdx/_tokens.scss` and -`packages/app/src/theme/themes/clickstack/_tokens.scss`, duplicated inside -each `[data-mantine-color-scheme]` selector. That duplication is -intentional and called out in the files — CSS specificity requires the -chart vars to live inside each scheme block. +`packages/app/src/theme/themes/clickstack/_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`. The palette is the same on every theme +today, so 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`. + +They're duplicated inside each `[data-mantine-color-scheme]` selector (both +schemes, both themes — four blocks). CSS specificity requires it; a shared block +would be overridden by per-scheme rules elsewhere. **If you change a hex in +`_tokens.scss`, change it in `CATEGORICAL_HEX_BY_TOKEN` too — they are +intentionally kept in sync.** Brand identity for charts is carried by the **semantic** tokens -(`--color-chart-success`, `-info`) and by non-chart UI chrome (Mantine -accent, sidebar gradient, Click UI globals), not by which hue happens to -appear at categorical slot 0. +(`--color-chart-success`, `-info`) and by non-chart UI chrome (Mantine accent, +sidebar gradient, Click UI globals), not by which hue happens to appear at +categorical slot 0. ### Semantic chart colors (`--color-chart-{success|warning|error|info}`) @@ -71,66 +91,77 @@ appear at categorical slot 0. --color-chart-error-highlight ``` -Defined in both `_tokens.scss` files. **Per-brand**: HyperDX uses brand -green for `success` and `info` (`#00c28a`); ClickStack uses Observable -green (`#3ca951`) for `success` and Observable blue (`#4269d0`) for -`info`. Warning and error are the same across themes. +Defined in both `_tokens.scss` files. **Per-brand**: HyperDX uses brand green +for `success` and `info` (`#00c28a`); ClickStack uses Observable green +(`#3ca951`) for `success` and brand blue (`#437eef`, same hue as the categorical +`chart-blue` and `--click-global-color-text-link-default`) for `info`. Warning +and error are the same across themes. + +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` reacting to +theme switches without a React re-render, and it lets JS callers like +`getChartColorSuccess()` return the correct hex for the active theme. ### Single source of truth in TS (`packages/app/src/utils.ts`) ```text -CATEGORICAL_HEX_BY_TOKEN # { 'chart-blue': '#4269d0', ... } — file-private -SEMANTIC_CHART_PALETTE # { hyperdx: {...}, clickstack: {...} } — file-private +CATEGORICAL_HEX_BY_TOKEN # { 'chart-blue': '#437eef', ... } — file-private, + # authoritative for categorical hues +SEMANTIC_CHART_PALETTE # { hyperdx: {...}, clickstack: {...} } — 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 exported as the SSR fallback for `getColorFromCSSVariable(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). +`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 | +| 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)` reads - `--color-chart-{CATEGORICAL_PALETTE_TOKENS[index]}` (e.g. - `--color-chart-blue` for `index=0`) from `documentElement` via - `getComputedStyle`. SSR / missing-var fallback is `COLORS[index]`. -- `getColorFromCSSToken(token)` reads `--color-{token}` directly. SSR - fallback resolves categorical tokens through `CATEGORICAL_HEX_BY_TOKEN` - and semantic tokens through the active theme's `SEMANTIC_CHART_PALETTE`. -- `getSemanticChartColor(varName, key)` uses `detectActiveTheme()` (checks - for the `theme-clickstack` class on ``) to pick the correct - per-brand 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)` 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 the active theme's `SEMANTIC_CHART_PALETTE` entry 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. It uses + `detectActiveTheme()` (checks for the `theme-clickstack` class on ``) to + pick the correct per-brand fallback. +- During SSR, semantic readers return the **HyperDX** default — the hydration + mismatch window is tiny because charts render after data fetch on the client. ### 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. +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: @@ -142,33 +173,30 @@ chart-3 -> chart-orange chart-10 -> chart-gray ``` -It preserves the HyperDX slot ordering from #2265, so HyperDX users see -no visual change. ClickStack users (whose stored `chart-1` historically -rendered as Observable blue at runtime) now see Observable green for -that token after migration — by design, since the slot now means a hue, -not "brand primary." Use `chart-info` semantic if you need the -brand-primary appearance. +It preserves the HyperDX slot ordering from #2265, so HyperDX users see no +visual change. ClickStack users (whose stored `chart-1` historically rendered as +Observable blue at runtime) now see Observable green for that token after +migration — by design, since the slot now means a hue, not "brand primary." 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. +**`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** — `normalizeDashboardTileColors` in - `packages/app/src/dashboard.ts` walks every tile in the response - from `useDashboards` / `fetchLocalDashboards` and rewrites any - legacy `config.color` to its hue-named equivalent via - `resolveChartPaletteToken`. This heals the in-memory copy - immediately; the persisted copy is updated the next time the user - saves the dashboard for any reason (color or otherwise). + `packages/app/src/dashboard.ts` walks every tile in the response from + `useDashboards` / `fetchLocalDashboards` and rewrites any legacy + `config.color` to its hue-named equivalent via `resolveChartPaletteToken`. + This heals the in-memory copy immediately; the persisted copy is updated the + next time the user saves the dashboard for any reason (color or otherwise). 2. **Render-time** — `DBNumberChart` and `ColorSwatchInput` also call - `resolveChartPaletteToken` as belt-and-suspenders for any code path - that bypasses the fetch normalizer (e.g. a tile constructed in - memory from a `Tile` literal). + `resolveChartPaletteToken` as belt-and-suspenders for any code path that + bypasses the fetch normalizer (e.g. a tile constructed in memory from a + `Tile` literal). `LEGACY_CHART_PALETTE_TOKEN_MAP` and `resolveChartPaletteToken` live in `packages/common-utils/src/types.ts` next to the enum. @@ -182,9 +210,9 @@ Migration happens at two complementary layers instead: - 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. +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** @@ -196,32 +224,31 @@ 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. + `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 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. +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. +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: @@ -235,9 +262,9 @@ 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. ### Identity color ("this thing is always blue") @@ -247,7 +274,7 @@ multi-series ordering: ```tsx import { getColorFromCSSToken } from '@/utils'; -Always blue +Always blue; ``` Or directly in CSS: @@ -265,27 +292,25 @@ 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 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. +`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 @@ -297,15 +322,15 @@ const { colorScheme } = useMantineColorScheme(); const palette = colorScheme === 'light' ? lightPalette : darkPalette; ``` -That's the only correct way to consume the heatmap gradient. Never -reconstruct it. +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: +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); @@ -319,61 +344,61 @@ const slices = sorted.map((d, i) => ({ ## Per-theme considerations -The categorical palette is identical on both themes — Observable 10. The -only place themes differ is the semantic chart layer. +The categorical palette is identical on both themes — Observable 10. The only +place themes differ is the semantic chart layer. ### HyperDX -- `--color-chart-success` and `--color-chart-info` use brand green - (`#00c28a`). +- `--color-chart-success` and `--color-chart-info` use brand green (`#00c28a`). - Single-series charts that pick a "neutral" / info-level color via `getChartColorInfo()` render brand green. -- Multi-series charts start at Observable blue (slot 0) and proceed - through the canonical palette — brand identity is preserved via the - Mantine green accent, sidebar gradient, and semantic chart tokens. +- Multi-series charts start at brand blue (slot 0, `#437eef`) and proceed + through the canonical palette — brand identity is preserved via the Mantine + green accent, sidebar gradient, and semantic chart tokens. ### ClickStack -- 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 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. - `--color-chart-success` uses Observable green (`#3ca951`). -- `--color-chart-info` uses Observable blue (`#4269d0`), so info-level - logs and `getChartColorInfo()` render blue. +- `--color-chart-info` uses brand blue (`#437eef`, same as the categorical + `chart-blue` and `--click-global-color-text-link-default`), so info-level logs + and `getChartColorInfo()` render the brand blue. ### Both themes -- 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 in `_tokens.scss` are intentionally duplicated across - the dark and light blocks. If you change one, change the other. +- 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 in `_tokens.scss` are intentionally duplicated across the dark + and light blocks. If you change one, change the other. ## 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: +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 **all four** - SCSS blocks (HyperDX dark, HyperDX light, ClickStack dark, ClickStack - light), 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`. +3. If you really must extend: add `--color-chart-{newhue}` to **all four** SCSS + blocks (HyperDX dark, HyperDX light, ClickStack dark, ClickStack light), + 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 per theme (HyperDX often uses brand variants; - ClickStack uses Observable variants). -2. Add `--color-chart-pending` and (if needed) - `--color-chart-pending-highlight` to **all four** SCSS blocks. +1. Pick the hex per theme (HyperDX often uses brand variants; ClickStack uses + Observable variants). +2. Add `--color-chart-pending` and (if needed) `--color-chart-pending-highlight` + to **all four** SCSS blocks. 3. Add `pending` (and optionally `pendingHighlight`) to `SEMANTIC_CHART_PALETTE.hyperdx` and `.clickstack` in `utils.ts`. 4. Append `'chart-pending'` to `CHART_PALETTE_TOKENS` (and @@ -381,8 +406,8 @@ descending order of preference: `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. +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 @@ -391,14 +416,14 @@ descending order of preference: ### 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. +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). +component-local like the existing ones, and re-use the same scheme-pick pattern +(`useMantineColorScheme` + ternary). ## Anti-patterns @@ -411,44 +436,43 @@ pattern (`useMantineColorScheme` + ternary). 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. +- **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). + `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 HyperDX ↔ ClickStack theme — semantic colors (success / info) change as expected; categorical palette stays identical. -- [ ] 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. +- [ ] 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 **all three** places (HyperDX SCSS, ClickStack SCSS, `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts`). -- [ ] If you added or changed a semantic hex, changed it in **all three** - places (the relevant theme's SCSS, `SEMANTIC_CHART_PALETTE.{theme}` in +- [ ] If you added or changed a semantic hex, changed it in **all three** places + (the relevant theme's SCSS, `SEMANTIC_CHART_PALETTE.{theme}` in `utils.ts`). - [ ] Storybook `Design Tokens / Chart Colors` still renders correctly. ## File reference summary -| What | Where | -| ------------------------------------------ | ---------------------------------------------------------------------------------- | -| HyperDX chart vars (dark + light) | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | -| ClickStack chart vars (dark + light) | `packages/app/src/theme/themes/clickstack/_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`) | +| What | Where | +| ------------------------------------------ | -------------------------------------------------------------------------------- | +| HyperDX chart vars (dark + light) | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | +| ClickStack chart vars (dark + light) | `packages/app/src/theme/themes/clickstack/_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`) | diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index ecce8ef5c4..ded15fb059 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1214,13 +1214,19 @@ describe('getColorFromCSSToken', () => { jest.restoreAllMocks(); }); - it('returns the CSS variable value when getComputedStyle provides one', () => { - jest.spyOn(global, 'getComputedStyle').mockReturnValue({ - getPropertyValue: (name: string) => - name === '--color-chart-blue' ? '#custom-blue' : '', - } as unknown as CSSStyleDeclaration); + it('returns the categorical hex directly from CATEGORICAL_HEX_BY_TOKEN without reading CSS', () => { + // Categorical tokens are unified across themes, so the resolver + // intentionally skips getComputedStyle to avoid a per-series + // layout read. A CSS-var override has no effect on the returned + // value (and shouldn't be relied upon by JS callers). + const getComputedStyleSpy = jest + .spyOn(global, 'getComputedStyle') + .mockReturnValue({ + getPropertyValue: () => '#should-be-ignored', + } as unknown as CSSStyleDeclaration); - expect(getColorFromCSSToken('chart-blue')).toBe('#custom-blue'); + expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); + expect(getComputedStyleSpy).not.toHaveBeenCalled(); }); it('returns the CSS variable value for semantic tokens when provided', () => { @@ -1232,39 +1238,19 @@ describe('getColorFromCSSToken', () => { expect(getColorFromCSSToken('chart-success')).toBe('#theme-green'); }); - it('falls back to COLORS[0] for chart-blue when the CSS variable is empty', () => { - jest.spyOn(global, 'getComputedStyle').mockReturnValue({ - getPropertyValue: () => '', - } as unknown as CSSStyleDeclaration); - - expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); - }); - - it('falls back to the SSR palette when getComputedStyle throws', () => { + it('falls back to SEMANTIC_CHART_PALETTE when getComputedStyle throws for semantic tokens', () => { jest.spyOn(global, 'getComputedStyle').mockImplementation(() => { throw new Error('getComputedStyle unavailable'); }); - // Semantic tokens fall back to the active theme's SEMANTIC_CHART_PALETTE - // entry (defaults to HyperDX in jsdom because the document has no - // theme-clickstack class). + // Defaults to HyperDX in jsdom because the document has no + // theme-clickstack class. expect(getColorFromCSSToken('chart-success')).toBe('#00c28a'); expect(getColorFromCSSToken('chart-warning')).toBe('#efb118'); expect(getColorFromCSSToken('chart-error')).toBe('#ff725c'); - // Categorical tokens fall back to their canonical hex in the unified - // Observable 10 palette. - expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); - expect(getColorFromCSSToken('chart-gray')).toBe(COLORS[9]); }); - it('falls back to the SSR palette for every categorical token in CATEGORICAL_PALETTE_TOKENS', () => { - // The throw-branch exercises the same paletteTokenSSRFallback code as the - // SSR branch (window === undefined), which jsdom prevents from being - // simulated via Object.defineProperty. - jest.spyOn(global, 'getComputedStyle').mockImplementation(() => { - throw new Error('not available'); - }); - + it('returns the canonical hex for every categorical token in CATEGORICAL_PALETTE_TOKENS', () => { utils.CATEGORICAL_PALETTE_TOKENS.forEach((token, i) => { expect(getColorFromCSSToken(token)).toBe(COLORS[i]); }); diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index d62a2f27f0..38f5379c7b 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -173,18 +173,27 @@ --color-json-punctuation: #666980; /* - * Chart Categorical Palette - Observable 10, unified across themes. - * Hue-named tokens resolve to the same hex on both HyperDX and - * ClickStack so a user's color choice means the same thing - * regardless of brand. Brand identity carries through the semantic - * --color-chart-success / -info tokens below and the Click UI accents. + * Chart Categorical Palette - based on Observable 10, with + * `chart-blue` swapped to the ClickStack brand link color (#437eef). + * Unified across themes. + * + * Stylesheet-author affordance only. The React rendering path reads + * these hues from `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` directly + * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip + * `getComputedStyle` for categorical tokens) — the palette is the + * same on every theme, so the round-trip would add a layout read per + * series for no functional gain. These vars exist so SCSS modules + * and inline `var()` consumers can reference hues without importing + * from JS, and for devtools inspection. If you change a hex here, + * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` + * to match — they are intentionally duplicated. * * NOTE: Duplicated across dark and light mode sections by design. * CSS specificity requires them to live inside each * [data-mantine-color-scheme] selector; a shared .theme-clickstack * section would have lower specificity and could be overridden. */ - --color-chart-blue: #4269d0; + --color-chart-blue: #437eef; --color-chart-orange: #efb118; --color-chart-red: #ff725c; --color-chart-cyan: #6cc5b0; @@ -195,11 +204,20 @@ --color-chart-brown: #9c6b4e; --color-chart-gray: #9498a0; - /* Chart Semantic Colors - per-brand (ClickStack blue for info, Observable green for success) */ + /* + * Chart Semantic Colors - per-brand. Unlike the categorical hues + * above, these vary by theme, so `getChartColor{Success,Warning,...}` + * in `utils.ts` reads them via `getComputedStyle` and reacts to a + * runtime theme switch without a React re-render. Inline `var()` + * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. + * ClickStack uses the brand blue (#437eef, same hue as + * `--click-global-color-text-link-default` and the categorical + * `chart-blue`) for `info` and Observable green for `success`. + */ --color-chart-success: #3ca951; --color-chart-warning: #efb118; --color-chart-error: #ff725c; - --color-chart-info: #4269d0; + --color-chart-info: #437eef; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; @@ -385,10 +403,10 @@ --color-json-array: #997300; --color-json-punctuation: #868e96; - /* Chart Categorical Palette - Observable 10, unified across themes. - Duplicated from the dark block: CSS specificity requires the chart + /* Chart Categorical Palette - see dark block above for the canonical + comment. Duplicated here because CSS specificity requires the chart vars to live inside each [data-mantine-color-scheme] selector. */ - --color-chart-blue: #4269d0; + --color-chart-blue: #437eef; --color-chart-orange: #efb118; --color-chart-red: #ff725c; --color-chart-cyan: #6cc5b0; @@ -399,11 +417,11 @@ --color-chart-brown: #9c6b4e; --color-chart-gray: #9498a0; - /* Chart Semantic Colors - per-brand (ClickStack blue for info, Observable green for success) */ + /* Chart Semantic Colors - per-brand (ClickStack brand blue #437eef for info, Observable green for success) */ --color-chart-success: #3ca951; --color-chart-warning: #efb118; --color-chart-error: #ff725c; - --color-chart-info: #4269d0; + --color-chart-info: #437eef; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index a73eddf7a0..c73ff2ec0c 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -92,14 +92,23 @@ --color-json-array: var(--mantine-color-green-3); --color-json-punctuation: var(--mantine-color-dark-4); - /* Chart Categorical Palette - Observable 10, unified across themes. - Hue-named tokens (chart-blue, chart-orange, ...) keep stored chart - configs self-documenting and resolve to the same hex on both - HyperDX and ClickStack so a user's color choice means the same - thing regardless of brand. Brand identity carries through the - semantic --color-chart-success / -info tokens below, plus the - Mantine accent and chrome elsewhere. */ - --color-chart-blue: #4269d0; + /* + * Chart Categorical Palette - based on Observable 10, with + * `chart-blue` swapped to the brand link color (#437eef). Unified + * across themes. + * + * Stylesheet-author affordance only. The React rendering path reads + * these hues from `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` directly + * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip + * `getComputedStyle` for categorical tokens) — the palette is the + * same on every theme, so the round-trip would add a layout read per + * series for no functional gain. These vars exist so SCSS modules + * and inline `var()` consumers can reference hues without importing + * from JS, and for devtools inspection. If you change a hex here, + * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` + * to match — they are intentionally duplicated. + */ + --color-chart-blue: #437eef; --color-chart-orange: #efb118; --color-chart-red: #ff725c; --color-chart-cyan: #6cc5b0; @@ -110,7 +119,13 @@ --color-chart-brown: #9c6b4e; --color-chart-gray: #9498a0; - /* Chart Semantic Colors - per-brand (HyperDX brand green for success / info) */ + /* + * Chart Semantic Colors - per-brand. Unlike the categorical hues + * above, these vary by theme, so `getChartColor{Success,Warning,...}` + * in `utils.ts` reads them via `getComputedStyle` and reacts to a + * runtime theme switch without a React re-render. Inline `var()` + * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. + */ --color-chart-success: #00c28a; --color-chart-warning: #efb118; --color-chart-error: #ff725c; @@ -211,10 +226,10 @@ --color-json-array: var(--mantine-color-green-7); --color-json-punctuation: var(--mantine-color-dark-5); - /* Chart Categorical Palette - Observable 10, unified across themes. - Duplicated from the dark block: CSS specificity requires the chart + /* Chart Categorical Palette - see dark block above for the canonical + comment. Duplicated here because CSS specificity requires the chart vars to live inside each [data-mantine-color-scheme] selector. */ - --color-chart-blue: #4269d0; + --color-chart-blue: #437eef; --color-chart-orange: #efb118; --color-chart-red: #ff725c; --color-chart-cyan: #6cc5b0; diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 24290297e9..341f92e33a 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -356,20 +356,29 @@ export const getLogLevelClass = (lvl: string | undefined) => { }; /** - * Chart Categorical Palette - Observable 10, unified across themes. - * https://observablehq.com/@d3/color-schemes + * 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. * - * Both HyperDX and ClickStack resolve `--color-chart-{hue}` to the - * same hex; per-brand identity carries through the semantic chart - * tokens below and through non-chart UI chrome (Mantine accent, - * sidebar gradient, etc.). + * **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 both `packages/app/src/theme/themes/{hyperdx,clickstack}/_tokens.scss`. */ const CATEGORICAL_HEX_BY_TOKEN = { - 'chart-blue': '#4269d0', + 'chart-blue': '#437eef', 'chart-orange': '#efb118', 'chart-red': '#ff725c', 'chart-cyan': '#6cc5b0', @@ -401,7 +410,7 @@ const SEMANTIC_CHART_PALETTE = { success: '#3ca951', warning: '#efb118', error: '#ff725c', - info: '#4269d0', + info: '#437eef', successHighlight: '#80d9b3', warningHighlight: '#f5c94d', errorHighlight: '#ffa090', @@ -413,8 +422,9 @@ type SemanticChartColorKey = keyof typeof SEMANTIC_CHART_PALETTE.hyperdx; /** * Ordered hex array for positional series assignment. * `COLORS[i]` === `CATEGORICAL_HEX_BY_TOKEN[CATEGORICAL_PALETTE_TOKENS[i]]`. - * SSR fallback only; in the browser, `getColorFromCSSVariable()` reads - * from the matching `--color-chart-{hue}` CSS variable. + * 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`. */ export const COLORS: string[] = CATEGORICAL_PALETTE_TOKENS.map( token => @@ -460,53 +470,48 @@ function detectActiveTheme(): 'clickstack' | 'hyperdx' { } /** - * Reads a categorical chart color from a CSS variable by index. + * Returns the Nth categorical chart hex by series index. Index wraps + * modulo `CATEGORICAL_PALETTE_TOKENS.length`. * - * Index N maps to the Nth token in `CATEGORICAL_PALETTE_TOKENS`, which - * in turn maps to `--color-chart-{hue}` in each theme's `_tokens.scss`. - * Falls back to `COLORS[N]` (the same hex the var resolves to, since - * the categorical palette is now unified across themes) when running - * server-side or when `getComputedStyle` fails. + * 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 i = index % CATEGORICAL_PALETTE_TOKENS.length; - const token = CATEGORICAL_PALETTE_TOKENS[i]; - - if (typeof window === 'undefined') { - return COLORS[i]; - } - - try { - const cssVarName = `--color-${token}`; - const computedStyle = getComputedStyle(document.documentElement); - const color = computedStyle.getPropertyValue(cssVarName).trim(); - if (color && color !== '') { - return color; - } - } catch { - // Fallback if getComputedStyle fails - } - return COLORS[i]; } /** - * Reads a chart color from a palette token by resolving the matching - * CSS variable (`--color-{token}`). + * 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 unified categorical palette (for hue tokens) or - * the active theme's semantic palette (for `chart-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-blue') // Observable blue (both themes) * getColorFromCSSToken('chart-warning') // theme-aware warning */ export function getColorFromCSSToken(token: ChartPaletteToken): string { + if (isCategoricalChartPaletteToken(token)) { + return CATEGORICAL_HEX_BY_TOKEN[token]; + } + const cssVarName = `--color-${token}`; if (typeof window === 'undefined') { - return paletteTokenSSRFallback(token); + return semanticTokenFallback(token); } try { @@ -519,10 +524,16 @@ export function getColorFromCSSToken(token: ChartPaletteToken): string { // Fallback if getComputedStyle fails } - return paletteTokenSSRFallback(token); + return semanticTokenFallback(token); +} + +function isCategoricalChartPaletteToken( + token: ChartPaletteToken, +): token is keyof typeof CATEGORICAL_HEX_BY_TOKEN { + return Object.prototype.hasOwnProperty.call(CATEGORICAL_HEX_BY_TOKEN, token); } -function paletteTokenSSRFallback(token: ChartPaletteToken): string { +function semanticTokenFallback(token: ChartPaletteToken): string { switch (token) { case 'chart-success': case 'chart-warning': @@ -535,11 +546,8 @@ function paletteTokenSSRFallback(token: ChartPaletteToken): string { return SEMANTIC_CHART_PALETTE[theme][key]; } default: - return ( - CATEGORICAL_HEX_BY_TOKEN[ - token as keyof typeof CATEGORICAL_HEX_BY_TOKEN - ] ?? COLORS[0] - ); + // Unknown token; fall back to brand-primary so we render *something*. + return COLORS[0]; } } From 7eb2b85713ebb8db59dd7bf34c804b8a631af0da Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 20:37:07 +0100 Subject: [PATCH 06/17] docs(changeset): note brand-blue customization, JS source of truth, and ClickStack info alignment Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 912c389ae5..d12fb49915 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -7,4 +7,6 @@ refactor(theme): rename chart palette tokens from chart-1..10 to hue-named (char Stored configs from the initial color picker (#2265) keep working. `ChartPaletteTokenSchema` stays strict (a plain `z.enum`, so its `z.input` matches `z.output` — wrapping it in `z.preprocess` would poison `validateRequest`'s `req.body` inference all the way up to `Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` happens in two complementary places: a fetch-time `normalizeDashboardTileColors` (in `packages/app/src/dashboard.ts`) heals the data once it lands in the client cache, and a new render-time `resolveChartPaletteToken` helper (in `common-utils/types.ts`, re-exported from `packages/app/src/utils.ts`) acts as belt-and-suspenders inside `DBNumberChart` and `ColorSwatchInput`. Both paths preserve the HyperDX slot ordering from #2265 (slot 1 = brand green, slot 2 = blue, etc.). -Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and the `getChartColorInfo()` helper resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to Observable blue. The categorical palette is now Observable 10, identical across themes — picking `chart-blue` always renders blue. +The categorical palette is based on Observable 10, 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. The palette resolves identically on both themes — picking `chart-blue` always renders the brand blue. Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and `getChartColorInfo()` resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to the same brand blue `#437eef` so info-level logs and primary series stay visually aligned with the link color. + +Internally, JS (`CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts`) is the source of truth for categorical hues — `getColorFromCSSVariable` and `getColorFromCSSToken` skip `getComputedStyle` for categorical tokens since the palette is unified across themes. The matching `--color-chart-{hue}` CSS vars in `_tokens.scss` remain as a stylesheet-author affordance (inline `var()` use, devtools inspection) and a hook for any future per-brand override. Semantic tokens still resolve through `getComputedStyle` because they genuinely vary per theme. From 90f0ff3817ce7a1d8008bd162e905b068535b944 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 21:12:34 +0100 Subject: [PATCH 07/17] refactor(theme): extract shared chart categorical SCSS partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10 categorical chart hues are unified across HyperDX and ClickStack, so duplicating them in each theme's `chart-tokens` mixin was redundant. Extract them into a single shared `_chart-categorical-tokens.scss` partial that both theme files `@use` and `@include`. Per-brand semantic tokens (success, info, warning, error) stay local to each theme's mixin, since those genuinely vary by brand. The compiled CSS is byte-identical to before — only comments differ, and selector specificity is preserved because Sass still inlines the mixin body at each `[data-mantine-color-scheme]` call site. Net -47 lines across the theme files despite adding the new partial. Also update the JS-side sync comment in utils.ts to point at the new shared SCSS source and refresh agent_docs/data_viz_colors.md (file reference table, pre-merge checklist, "add a new hue" recipe now require one SCSS edit instead of two). Co-authored-by: Cursor --- agent_docs/data_viz_colors.md | 66 +++++----- .../themes/_chart-categorical-tokens.scss | 41 ++++++ .../src/theme/themes/clickstack/_tokens.scss | 118 +++++++----------- .../app/src/theme/themes/hyperdx/_tokens.scss | 106 ++++++---------- packages/app/src/utils.ts | 5 +- 5 files changed, 165 insertions(+), 171 deletions(-) create mode 100644 packages/app/src/theme/themes/_chart-categorical-tokens.scss diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index e0fd0f797f..82f7607916 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -68,11 +68,17 @@ The CSS vars exist for: `getColorFromCSSVariable`/`getColorFromCSSToken` back to reading the var and add per-brand entries to `CATEGORICAL_HEX_BY_TOKEN`. -They're duplicated inside each `[data-mantine-color-scheme]` selector (both -schemes, both themes — four blocks). CSS specificity requires it; a shared block -would be overridden by per-scheme rules elsewhere. **If you change a hex in -`_tokens.scss`, change it in `CATEGORICAL_HEX_BY_TOKEN` too — they are -intentionally kept in sync.** +The 10 categorical hues live in a single shared partial, +`packages/app/src/theme/themes/_chart-categorical-tokens.scss`, which both brand +themes `@use` and `@include` inside their per-theme `chart-tokens` mixin. Each +theme's `chart-tokens` mixin 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** for the +unified categorical layer (the shared partial) and one block per theme for the +per-brand semantic layer. **If you change a hex in the shared partial, change it +in `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` too — the SCSS and JS sources are +intentionally mirrored.** Brand identity for charts is carried by the **semantic** tokens (`--color-chart-success`, `-info`) and by non-chart UI chrome (Mantine accent, @@ -386,19 +392,22 @@ 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 **all four** SCSS - blocks (HyperDX dark, HyperDX light, ClickStack dark, ClickStack light), - 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`. +3. If you really must extend: add `--color-chart-{newhue}` to the shared + `_chart-categorical-tokens.scss` partial — one edit covers both brands and + 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 per theme (HyperDX often uses brand variants; ClickStack uses Observable variants). 2. Add `--color-chart-pending` and (if needed) `--color-chart-pending-highlight` - to **all four** SCSS blocks. + to the `@mixin chart-tokens` block in both theme files (HyperDX and + ClickStack `_tokens.scss`). The mixin is `@include`'d in each scheme + selector, so two edits cover dark and light for both brands. 3. Add `pending` (and optionally `pendingHighlight`) to `SEMANTIC_CHART_PALETTE.hyperdx` and `.clickstack` in `utils.ts`. 4. Append `'chart-pending'` to `CHART_PALETTE_TOKENS` (and @@ -455,24 +464,25 @@ Why each is wrong: 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 **all three** - places (HyperDX SCSS, ClickStack SCSS, `CATEGORICAL_HEX_BY_TOKEN` in - `utils.ts`). +- [ ] 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 **all three** places - (the relevant theme's SCSS, `SEMANTIC_CHART_PALETTE.{theme}` in - `utils.ts`). + (the relevant theme's `chart-tokens` mixin in `_tokens.scss`, + `SEMANTIC_CHART_PALETTE.{theme}` in `utils.ts`). - [ ] Storybook `Design Tokens / Chart Colors` still renders correctly. ## File reference summary -| What | Where | -| ------------------------------------------ | -------------------------------------------------------------------------------- | -| HyperDX chart vars (dark + light) | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | -| ClickStack chart vars (dark + light) | `packages/app/src/theme/themes/clickstack/_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`) | +| What | Where | +| --------------------------------------------------- | -------------------------------------------------------------------------------- | +| Shared categorical chart vars (10 hues) | `packages/app/src/theme/themes/_chart-categorical-tokens.scss` | +| HyperDX semantic chart vars + chart-tokens mixin | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | +| ClickStack semantic chart vars + chart-tokens mixin | `packages/app/src/theme/themes/clickstack/_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`) | 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..0678ee6d0d --- /dev/null +++ b/packages/app/src/theme/themes/_chart-categorical-tokens.scss @@ -0,0 +1,41 @@ +/* Shared Chart Categorical Tokens - used by both theme files */ + +/* + * The 10-hue categorical chart palette is genuinely unified across + * brands (HyperDX and ClickStack both resolve `--color-chart-{hue}` + * to the same hex). This file is the SCSS source of truth for those + * hues; both theme files `@use` it and `@include` the mixin inside + * their per-theme `chart-tokens` mixin. + * + * Per-brand chart identity (success, info, warning, error) lives in + * each theme's `chart-tokens` mixin alongside this `@include`. Those + * semantic tokens genuinely vary per brand, so they don't belong here. + * + * The React rendering path reads these hues from + * `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` directly + * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip + * `getComputedStyle` for categorical tokens) — the palette is the + * same on every theme, so the round-trip would add a layout read per + * series for no functional gain. These vars exist so SCSS modules + * and inline `var()` consumers can reference hues without importing + * from JS, and for devtools inspection. If you change a hex here, + * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` + * to match — the SCSS and JS sources are intentionally mirrored. + * + * `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; +} diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 38f5379c7b..823331c326 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -4,6 +4,47 @@ /* 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. The categorical hues themselves come from the shared + * `chart-categorical-tokens` partial, since they're unified across + * brands; only the per-brand semantic tokens live in this file. + */ + +@use '../chart-categorical-tokens' as shared-chart; + +@mixin chart-tokens { + /* Categorical Palette - shared with HyperDX (10 unified hues). */ + @include shared-chart.chart-categorical-tokens; + + /* + * Chart Semantic Colors - per-brand. Unlike the categorical hues + * above, these vary by theme, so `getChartColor{Success,Warning,...}` + * in `utils.ts` reads them via `getComputedStyle` and reacts to a + * runtime theme switch without a React re-render. Inline `var()` + * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. + * ClickStack uses the brand blue (#437eef, same hue as + * `--click-global-color-text-link-default` and the categorical + * `chart-blue`) for `info` and Observable green for `success`. + * Within ClickStack, the dark and light schemes happen to use the + * same hexes, which is why they're inside `chart-tokens` rather + * than the scheme-specific blocks. + */ + --color-chart-success: #3ca951; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #437eef; + + /* Chart Semantic Colors - Highlighted (for hover/selection states) */ + --color-chart-success-highlight: #80d9b3; + --color-chart-warning-highlight: #f5c94d; + --color-chart-error-highlight: #ffa090; +} + /* Dark Mode */ .theme-clickstack[data-mantine-color-scheme='dark'] { /* Brand Palette - Yellow/Gold */ @@ -172,57 +213,7 @@ --color-json-array: #ffd966; --color-json-punctuation: #666980; - /* - * Chart Categorical Palette - based on Observable 10, with - * `chart-blue` swapped to the ClickStack brand link color (#437eef). - * Unified across themes. - * - * Stylesheet-author affordance only. The React rendering path reads - * these hues from `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` directly - * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip - * `getComputedStyle` for categorical tokens) — the palette is the - * same on every theme, so the round-trip would add a layout read per - * series for no functional gain. These vars exist so SCSS modules - * and inline `var()` consumers can reference hues without importing - * from JS, and for devtools inspection. If you change a hex here, - * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` - * to match — they are intentionally duplicated. - * - * NOTE: Duplicated across dark and light mode sections by design. - * CSS specificity requires them to live inside each - * [data-mantine-color-scheme] selector; a shared .theme-clickstack - * section would have lower specificity and could be overridden. - */ - --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; - - /* - * Chart Semantic Colors - per-brand. Unlike the categorical hues - * above, these vary by theme, so `getChartColor{Success,Warning,...}` - * in `utils.ts` reads them via `getComputedStyle` and reacts to a - * runtime theme switch without a React re-render. Inline `var()` - * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. - * ClickStack uses the brand blue (#437eef, same hue as - * `--click-global-color-text-link-default` and the categorical - * `chart-blue`) for `info` and Observable green for `success`. - */ - --color-chart-success: #3ca951; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #437eef; - - /* 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; @@ -403,30 +394,7 @@ --color-json-array: #997300; --color-json-punctuation: #868e96; - /* Chart Categorical Palette - see dark block above for the canonical - comment. Duplicated here because CSS specificity requires the chart - vars to live inside each [data-mantine-color-scheme] selector. */ - --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; - - /* Chart Semantic Colors - per-brand (ClickStack brand blue #437eef for info, Observable green for success) */ - --color-chart-success: #3ca951; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #437eef; - - /* 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 c73ff2ec0c..946f23c03d 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -6,8 +6,45 @@ * 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. The categorical hues themselves come from the shared + * `chart-categorical-tokens` partial, since they're unified across + * brands; only the per-brand semantic tokens live in this file. */ +@use '../chart-categorical-tokens' as shared-chart; + +@mixin chart-tokens { + /* Categorical Palette - shared with ClickStack (10 unified hues). */ + @include shared-chart.chart-categorical-tokens; + + /* + * Chart Semantic Colors - per-brand (HyperDX brand green for + * `success` and `info`). Unlike the categorical hues above, semantic + * tokens vary by brand theme, so `getChartColor{Success,Warning,...}` + * in `utils.ts` reads them via `getComputedStyle` and reacts to a + * runtime theme switch without a React re-render. Inline `var()` + * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. + * Within HyperDX, the dark and light schemes happen to use the same + * hexes, which is why they're inside `chart-tokens` rather than the + * scheme-specific mixins. + */ + --color-chart-success: #00c28a; + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --color-chart-info: #00c28a; + + /* Chart Semantic Colors - Highlighted (for hover/selection states) */ + --color-chart-success-highlight: #80d9b3; + --color-chart-warning-highlight: #f5c94d; + --color-chart-error-highlight: #ffa090; +} + @mixin dark-mode-tokens { /* Backgrounds */ --color-bg-body: var(--mantine-color-dark-9); @@ -92,49 +129,7 @@ --color-json-array: var(--mantine-color-green-3); --color-json-punctuation: var(--mantine-color-dark-4); - /* - * Chart Categorical Palette - based on Observable 10, with - * `chart-blue` swapped to the brand link color (#437eef). Unified - * across themes. - * - * Stylesheet-author affordance only. The React rendering path reads - * these hues from `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` directly - * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip - * `getComputedStyle` for categorical tokens) — the palette is the - * same on every theme, so the round-trip would add a layout read per - * series for no functional gain. These vars exist so SCSS modules - * and inline `var()` consumers can reference hues without importing - * from JS, and for devtools inspection. If you change a hex here, - * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` - * to match — they are intentionally duplicated. - */ - --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; - - /* - * Chart Semantic Colors - per-brand. Unlike the categorical hues - * above, these vary by theme, so `getChartColor{Success,Warning,...}` - * in `utils.ts` reads them via `getComputedStyle` and reacts to a - * runtime theme switch without a React re-render. Inline `var()` - * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. - */ - --color-chart-success: #00c28a; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #00c28a; - - /* 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; @@ -226,30 +221,7 @@ --color-json-array: var(--mantine-color-green-7); --color-json-punctuation: var(--mantine-color-dark-5); - /* Chart Categorical Palette - see dark block above for the canonical - comment. Duplicated here because CSS specificity requires the chart - vars to live inside each [data-mantine-color-scheme] selector. */ - --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; - - /* Chart Semantic Colors - per-brand (HyperDX brand green for success / info) */ - --color-chart-success: #00c28a; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #00c28a; - - /* 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 341f92e33a..1654a7be39 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -375,7 +375,9 @@ export const getLogLevelClass = (lvl: string | undefined) => { * * Keep in sync with `CATEGORICAL_PALETTE_TOKENS` in * `@hyperdx/common-utils/dist/types` and with the `--color-chart-{hue}` - * vars in both `packages/app/src/theme/themes/{hyperdx,clickstack}/_tokens.scss`. + * vars in `packages/app/src/theme/themes/_chart-categorical-tokens.scss` + * (the single shared SCSS source for categorical hues; both brand + * themes `@use` it). */ const CATEGORICAL_HEX_BY_TOKEN = { 'chart-blue': '#437eef', @@ -443,6 +445,7 @@ export const COLORS: string[] = CATEGORICAL_PALETTE_TOKENS.map( export { CATEGORICAL_PALETTE_TOKENS, CHART_PALETTE_TOKENS, + isChartPaletteToken, resolveChartPaletteToken, SEMANTIC_PALETTE_TOKENS, } from '@hyperdx/common-utils/dist/types'; From ba3d3491aacf46b27e1473e86701da643c9021f7 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 21:22:15 +0100 Subject: [PATCH 08/17] fix(theme): heal legacy chart colors at write time + close coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Deep Review P2 findings on the chart palette rename PR: 1. PATCH/POST validation hole — the fetch-time normalizer only healed in-memory copies, so dashboards constructed outside the fetch path (JSON imports via DBDashboardImportPage, presets, MCP payloads) carried legacy `chart-1`..`chart-10` straight into the strict server-side `ChartPaletteTokenSchema` and returned a Zod 400. Apply `normalizeDashboardTileColors` symmetrically at write time in `useUpdateDashboard` and `useCreateDashboard` (both local and remote branches). This also converges the DB-side data on next save instead of holding legacy tokens in storage forever. The normalizer is now generic over `T extends { tiles?: Tile[] }` and returns the payload unchanged when tiles is omitted, so partial PATCHes still work. 2. ClickStack legacy slot-flip — pre-rename ClickStack used a different slot ordering than HyperDX (`--color-chart-1` was brand blue, not brand green; `--color-chart-2` was orange, not blue; etc.). Since the migration map preserves HyperDX slot ordering, any ClickStack dashboard saved via #2265 will visually shift. We chose this trade-off deliberately over branching the legacy map by `detectActiveTheme()`: `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. Documented the trade-off prominently in the changeset and agent_docs so reviewers and future maintainers can find it. 3. Remote-path test coverage — the existing dashboard test suite mocks `IS_LOCAL_MODE: true` for the entire file, so the `hdxServer('dashboards').json().then(...).map( normalizeDashboardTileColors)` branch had no coverage. Added `dashboard.remote.test.ts` (separate file because `jest.mock` is hoisted and per-test isolation via `jest.isolateModulesAsync` proved unreliable for swapping the constant). Exported `fetchDashboards` so it can be exercised directly. Tests cover: legacy migration on the remote path, hue-named tokens passing through unchanged, and tile identity preservation for the React reconciliation hot path. Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 55 +++++++- agent_docs/data_viz_colors.md | 40 ++++-- .../src/__tests__/dashboard.remote.test.ts | 125 ++++++++++++++++++ packages/app/src/dashboard.ts | 35 +++-- packages/common-utils/src/types.ts | 13 +- 5 files changed, 233 insertions(+), 35 deletions(-) create mode 100644 packages/app/src/__tests__/dashboard.remote.test.ts diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index d12fb49915..66387932c7 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -1,12 +1,55 @@ --- -"@hyperdx/common-utils": minor -"@hyperdx/app": minor +'@hyperdx/common-utils': minor +'@hyperdx/app': minor --- -refactor(theme): rename chart palette tokens from chart-1..10 to hue-named (chart-blue, chart-orange, ...) and unify the categorical palette across HyperDX and ClickStack +refactor(theme): rename chart palette tokens from chart-1..10 to hue-named +(chart-blue, chart-orange, ...) and unify the categorical palette across HyperDX +and ClickStack -Stored configs from the initial color picker (#2265) keep working. `ChartPaletteTokenSchema` stays strict (a plain `z.enum`, so its `z.input` matches `z.output` — wrapping it in `z.preprocess` would poison `validateRequest`'s `req.body` inference all the way up to `Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` happens in two complementary places: a fetch-time `normalizeDashboardTileColors` (in `packages/app/src/dashboard.ts`) heals the data once it lands in the client cache, and a new render-time `resolveChartPaletteToken` helper (in `common-utils/types.ts`, re-exported from `packages/app/src/utils.ts`) acts as belt-and-suspenders inside `DBNumberChart` and `ColorSwatchInput`. Both paths preserve the HyperDX slot ordering from #2265 (slot 1 = brand green, slot 2 = blue, etc.). +Stored configs from the initial color picker (#2265) keep working. +`ChartPaletteTokenSchema` stays strict (a plain `z.enum`, so its `z.input` +matches `z.output` — wrapping it in `z.preprocess` would poison +`validateRequest`'s `req.body` inference all the way up to +`Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` +happens in two complementary places: a `normalizeDashboardTileColors` helper (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 server-side schema. A +render-time `resolveChartPaletteToken` helper (in `common-utils/types.ts`, +re-exported from `packages/app/src/utils.ts`) acts as belt-and-suspenders inside +`DBNumberChart` and `ColorSwatchInput` for tiles constructed in memory between +fetch and save. Both paths preserve the HyperDX slot ordering from #2265 (slot 1 += brand green, slot 2 = blue, etc.). -The categorical palette is based on Observable 10, 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. The palette resolves identically on both themes — picking `chart-blue` always renders the brand blue. Brand identity for charts moves entirely into the semantic layer: HyperDX info logs and `getChartColorInfo()` resolve to brand green via the new `--color-chart-info` token, while ClickStack info resolves to the same brand blue `#437eef` so info-level logs and primary series stay visually aligned with the link color. +**ClickStack legacy color caveat:** Pre-rename ClickStack used a different slot +ordering than HyperDX (`--color-chart-1` was brand blue `#437eef`, not brand +green). The migration map uses HyperDX slot ordering, so any ClickStack +dashboard saved via #2265 with `color: 'chart-1'` will flip from blue to +Observable green after migration. 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. -Internally, JS (`CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts`) is the source of truth for categorical hues — `getColorFromCSSVariable` and `getColorFromCSSToken` skip `getComputedStyle` for categorical tokens since the palette is unified across themes. The matching `--color-chart-{hue}` CSS vars in `_tokens.scss` remain as a stylesheet-author affordance (inline `var()` use, devtools inspection) and a hook for any future per-brand override. Semantic tokens still resolve through `getComputedStyle` because they genuinely vary per theme. +The categorical palette is based on Observable 10, 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. The palette resolves identically on both themes — picking +`chart-blue` always renders the brand blue. Brand identity for charts moves +entirely into the semantic layer: HyperDX info logs and `getChartColorInfo()` +resolve to brand green via the new `--color-chart-info` token, while ClickStack +info resolves to the same brand blue `#437eef` so info-level logs and primary +series stay visually aligned with the link color. + +Internally, JS (`CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts`) is +the source of truth for categorical hues — `getColorFromCSSVariable` and +`getColorFromCSSToken` skip `getComputedStyle` for categorical tokens since the +palette is unified across themes. The matching `--color-chart-{hue}` CSS vars in +`_tokens.scss` remain as a stylesheet-author affordance (inline `var()` use, +devtools inspection) and a hook for any future per-brand override. Semantic +tokens still resolve through `getComputedStyle` because they genuinely vary per +theme. diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 82f7607916..180af9f846 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -180,10 +180,21 @@ chart-10 -> chart-gray ``` It preserves the HyperDX slot ordering from #2265, so HyperDX users see no -visual change. ClickStack users (whose stored `chart-1` historically rendered as -Observable blue at runtime) now see Observable green for that token after -migration — by design, since the slot now means a hue, not "brand primary." Use -`chart-info` semantic if you need the brand-primary appearance. +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 @@ -193,16 +204,19 @@ important than a one-line runtime migration buried in the schema. Migration happens at two complementary layers instead: -1. **Fetch-time** — `normalizeDashboardTileColors` in - `packages/app/src/dashboard.ts` walks every tile in the response from - `useDashboards` / `fetchLocalDashboards` and rewrites any legacy - `config.color` to its hue-named equivalent via `resolveChartPaletteToken`. - This heals the in-memory copy immediately; the persisted copy is updated the - next time the user saves the dashboard for any reason (color or otherwise). +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 any code path that - bypasses the fetch normalizer (e.g. a tile constructed in memory from a - `Tile` literal). + `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. diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts new file mode 100644 index 0000000000..55b6aa97fd --- /dev/null +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -0,0 +1,125 @@ +/* + * Remote-path tests for `fetchDashboards`. Kept in a separate file + * from `dashboard.test.ts` because that file mocks `IS_LOCAL_MODE` + * to `true` for the entire suite — switching the same constant per + * test via `jest.isolateModules` + `jest.doMock` proved unreliable + * because `jest.mock` declarations are hoisted to the top of the + * file. A second test file with its own file-level mocks is the + * idiomatic Jest pattern for exercising the alternate branch. + */ + +const mockJson = jest.fn(); + +jest.mock('../api', () => ({ + hdxServer: jest.fn(() => ({ json: mockJson })), +})); +jest.mock('../config', () => ({ IS_LOCAL_MODE: false })); +jest.mock('@mantine/notifications', () => ({ + notifications: { show: jest.fn() }, +})); +jest.mock('nuqs', () => ({ + parseAsJson: jest.fn(), + useQueryState: jest.fn(), +})); +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), + useQueryClient: jest.fn(), +})); +jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) })); + +import { fetchDashboards } from '../dashboard'; + +beforeEach(() => { + mockJson.mockReset(); +}); + +describe('fetchDashboards remote-path normalization', () => { + // The local-mode path is covered by `dashboard.test.ts` via + // `fetchLocalDashboards`. This file mirrors that coverage for the + // `hdxServer('dashboards').json().then(...).map( + // normalizeDashboardTileColors)` branch so the remote path doesn't + // silently regress. + it('migrates legacy chart-1..10 colors from server response', async () => { + mockJson.mockResolvedValue([ + { + id: 'a', + name: 'A', + tiles: [ + { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-1' }, + }, + { + id: 't2', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-2' }, + }, + { + id: 't10', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-10' }, + }, + ], + tags: [], + }, + ]); + const result = await fetchDashboards(); + expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-green' }); + expect(result[0].tiles[1].config).toMatchObject({ color: 'chart-blue' }); + expect(result[0].tiles[2].config).toMatchObject({ color: 'chart-gray' }); + }); + + it('leaves hue-named tokens unchanged on the remote path', async () => { + mockJson.mockResolvedValue([ + { + id: 'a', + name: 'A', + tiles: [ + { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-orange' }, + }, + ], + tags: [], + }, + ]); + const result = await fetchDashboards(); + expect(result[0].tiles[0].config).toMatchObject({ + color: 'chart-orange', + }); + }); + + it('preserves tile identity when no migration is needed', async () => { + // Reconciliation hot path: if a fetched dashboard has nothing to + // heal, the helper returns the same object reference so React's + // `useQuery` consumers don't see a synthetic change. + const tile = { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-blue' }, + }; + const dashboard = { id: 'a', name: 'A', tiles: [tile], tags: [] }; + mockJson.mockResolvedValue([dashboard]); + const result = await fetchDashboards(); + expect(result[0]).toBe(dashboard); + expect(result[0].tiles[0]).toBe(tile); + }); +}); diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 376950f031..d33daecc90 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -52,12 +52,23 @@ const localDashboards = createEntityStore('hdx-local-dashboards'); * into their hue-named equivalents. Applied at fetch time so every * downstream consumer (renderers, the color picker, save mutations) * sees the canonical hue tokens that `ChartPaletteTokenSchema` - * accepts. Unknown strings are left untouched so we don't accidentally - * erase forward-compat values; hue tokens already match the resolver - * and pass through unchanged. The returned tile array preserves - * identity where nothing changed so React reconciliation stays cheap. + * accepts, AND at write time so dashboards constructed outside the + * fetch path (JSON imports via `DBDashboardImportPage`, presets, MCP + * payloads) don't trip the strict server-side enum validator and + * return a Zod 400. Symmetric application also lets the DB-side data + * converge on next save instead of holding legacy tokens forever. + * + * Unknown strings are left untouched so we don't accidentally erase + * forward-compat values; hue tokens already match the resolver and + * pass through unchanged. The returned tile array preserves identity + * where nothing changed so React reconciliation stays cheap and the + * helper is safe to call on payloads that omit `tiles` entirely + * (partial PATCHes). */ -function normalizeDashboardTileColors(dashboard: Dashboard): Dashboard { +function normalizeDashboardTileColors( + dashboard: T, +): T { + if (!dashboard.tiles || dashboard.tiles.length === 0) return dashboard; let changed = false; const tiles = dashboard.tiles.map(tile => { const config = tile.config as { color?: unknown }; @@ -74,7 +85,7 @@ function normalizeDashboardTileColors(dashboard: Dashboard): Dashboard { return changed ? { ...dashboard, tiles } : dashboard; } -async function fetchDashboards(): Promise { +export async function fetchDashboards(): Promise { if (IS_LOCAL_MODE) { return localDashboards.getAll().map(normalizeDashboardTileColors); } @@ -89,14 +100,15 @@ export function useUpdateDashboard() { mutationFn: async ( dashboard: Partial & { id: Dashboard['id'] }, ) => { + const normalized = normalizeDashboardTileColors(dashboard); if (IS_LOCAL_MODE) { - const { id, ...updates } = dashboard; + const { id, ...updates } = normalized; localDashboards.update(id, updates); return; } - await hdxServer(`dashboards/${dashboard.id}`, { + await hdxServer(`dashboards/${normalized.id}`, { method: 'PATCH', - json: dashboard, + json: normalized, }); }, onSuccess: () => { @@ -110,12 +122,13 @@ export function useCreateDashboard() { return useMutation({ mutationFn: async (dashboard: Omit) => { + const normalized = normalizeDashboardTileColors(dashboard); if (IS_LOCAL_MODE) { - return localDashboards.create(dashboard); + return localDashboards.create(normalized); } return hdxServer('dashboards', { method: 'POST', - json: dashboard, + json: normalized, }).json(); }, onSuccess: () => { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index aa19e9d2e9..ed7828cea2 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -887,12 +887,15 @@ function isLegacyChartPaletteToken( * * 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 fetch-time normalizer + * in `ColorSwatchInput`, etc.). The app's normalizer * (`normalizeDashboardTileColors` in `packages/app/src/dashboard.ts`) - * heals dashboards loaded via `useDashboards` / `fetchLocalDashboards`, - * but tiles constructed in memory (presets, `ChartEditor` form state, - * unit-test fixtures) bypass that path, so render-time consumers still - * need this helper as defense in depth. + * 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, From 78cfce790ad4f43b721094eaf4ae65adb8262fa9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 29 May 2026 03:27:25 +0000 Subject: [PATCH 09/17] test(theme): cover fetchDashboards remote-path normalizer + redrop knip orphan Two follow-ups on #2362 after the latest pass: - Re-drop the `isChartPaletteToken` re-export in packages/app/src/utils.ts. The earlier dedupe-and-cleanup commit removed it, but the subsequent "unify categorical color palette and streamline color retrieval" pass re-added it as part of the reordered export block. Nothing in `packages/app/` imports it (both former call sites moved to `resolveChartPaletteToken`), the common-utils-level export remains for `guards.test.ts`. Knip CI flagged this on the most recent push. - Add a `dashboard.remote.test.ts` companion that exercises `fetchDashboards` in the non-local branch (`hdxServer('dashboards') .json<>()`). Symmetric coverage with the existing local-path suite in `dashboard.test.ts`; closes the deep-review's fetch-time normalizer testing gap (chart-1..chart-10 migration, hue passthrough, unknown preserved, missing-color skipped). Split into a separate file because `IS_LOCAL_MODE` is bound at module load and `jest.doMock` inside `jest.isolateModules` did not override the hoisted `jest.mock('../config', ...)` factory reliably here; a second file with its own top-level mocks is the cleanest fix. Required exporting `fetchDashboards` from `packages/app/src/dashboard.ts` (it's a thin wrapper around the same `normalizeDashboardTileColors` the existing local-path tests exercise). Comment flags it as test-only; the React layer should stay on `useDashboards`. Validated locally: lint, tsc, knip clean; common-utils unit (1145/1145), and the touched app suites (dashboard, dashboard.remote, utils, ColorSwatchInput, DBNumberChart) all pass (147/147 across the slice). Co-Authored-By: Claude Opus 4.7 --- .../src/__tests__/dashboard.remote.test.ts | 106 ++++++++++++++++++ packages/app/src/dashboard.ts | 3 +- packages/app/src/utils.ts | 1 - 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/__tests__/dashboard.remote.test.ts diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts new file mode 100644 index 0000000000..44d96c0089 --- /dev/null +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -0,0 +1,106 @@ +// Mirrors the local-mode tests in `dashboard.test.ts` but exercises the +// non-local branch of `fetchDashboards`: `hdxServer('dashboards').json<>()` +// followed by the same `normalizeDashboardTileColors` pass. The two files +// are split because `IS_LOCAL_MODE` is bound at module load and the two +// branches need different top-level mocks; `jest.doMock` inside +// `jest.isolateModules` did not override the hoisted `jest.mock` factory +// reliably enough to share a file. +jest.mock('../config', () => ({ IS_LOCAL_MODE: false })); +jest.mock('../api', () => ({ hdxServer: jest.fn() })); +jest.mock('@mantine/notifications', () => ({ + notifications: { show: jest.fn() }, +})); +jest.mock('nuqs', () => ({ + parseAsJson: jest.fn(), + useQueryState: jest.fn(), +})); +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), + useQueryClient: jest.fn(), +})); +jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) })); + +import { hdxServer } from '../api'; +import { fetchDashboards } from '../dashboard'; + +const hdxServerMock = hdxServer as jest.Mock; + +const remoteDashboardWithTileColor = (color: unknown) => [ + { + id: 'a', + name: 'A', + tiles: [{ id: 't1', x: 0, y: 0, w: 4, h: 4, config: { color } }], + tags: [], + }, +]; + +const setRemotePayload = (payload: unknown) => { + hdxServerMock.mockReturnValue({ + json: jest.fn().mockResolvedValue(payload), + }); +}; + +beforeEach(() => { + hdxServerMock.mockReset(); +}); + +describe('fetchDashboards (remote path)', () => { + // Stored configs from #2265 (the initial number-tile color picker) + // contain `color: 'chart-1'..'chart-10'`. The fetch-time normalizer + // heals those values for any tile that comes back from the API, so + // downstream consumers see the canonical hue tokens that + // `ChartPaletteTokenSchema` accepts. Symmetric coverage with the + // local-path suite in `dashboard.test.ts`. + it('migrates legacy chart-1..10 tokens from a remote payload', async () => { + setRemotePayload(remoteDashboardWithTileColor('chart-1')); + + const result = await fetchDashboards(); + + expect(hdxServerMock).toHaveBeenCalledWith('dashboards'); + expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-green' }); + }); + + it('migrates chart-10 to chart-gray on the remote path', async () => { + setRemotePayload(remoteDashboardWithTileColor('chart-10')); + + const result = await fetchDashboards(); + + expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-gray' }); + }); + + it('passes through hue-named tokens unchanged', async () => { + setRemotePayload(remoteDashboardWithTileColor('chart-orange')); + + const result = await fetchDashboards(); + + expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-orange' }); + }); + + it('preserves unknown color strings (no silent data loss)', async () => { + setRemotePayload(remoteDashboardWithTileColor('chart-future-magenta')); + + const result = await fetchDashboards(); + + expect(result[0].tiles[0].config).toMatchObject({ + color: 'chart-future-magenta', + }); + }); + + it('does not touch tiles whose config has no color field', async () => { + setRemotePayload([ + { + id: 'a', + name: 'A', + tiles: [ + { id: 't1', x: 0, y: 0, w: 4, h: 4, config: { displayType: 1 } }, + ], + tags: [], + }, + ]); + + const result = await fetchDashboards(); + + expect(result[0].tiles[0].config).toEqual({ displayType: 1 }); + }); +}); diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 376950f031..43eb49eb63 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -74,7 +74,8 @@ function normalizeDashboardTileColors(dashboard: Dashboard): Dashboard { return changed ? { ...dashboard, tiles } : dashboard; } -async function fetchDashboards(): Promise { +// Exported for testing; the React layer should reach for `useDashboards`. +export async function fetchDashboards(): Promise { if (IS_LOCAL_MODE) { return localDashboards.getAll().map(normalizeDashboardTileColors); } diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 1654a7be39..ce00e38711 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -445,7 +445,6 @@ export const COLORS: string[] = CATEGORICAL_PALETTE_TOKENS.map( export { CATEGORICAL_PALETTE_TOKENS, CHART_PALETTE_TOKENS, - isChartPaletteToken, resolveChartPaletteToken, SEMANTIC_PALETTE_TOKENS, } from '@hyperdx/common-utils/dist/types'; From 131d73e450753faf241dbeea066db9406315fb82 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 10:23:06 +0100 Subject: [PATCH 10/17] fix(theme): decouple HyperDX chart-info from success, reuse categorical cyan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously HyperDX `--color-chart-info` and `--color-chart-success` both resolved to the brand green `#00c28a`, so info-level series and success fills collapsed into the same color. Point `info` at Observable cyan (`#6cc5b0`, the categorical `chart-cyan` hex) instead. Now both brands follow the same pattern of having `info` reuse a categorical hue (HyperDX → cyan, ClickStack → blue), and HyperDX's two semantic tokens are visually distinct. - HyperDX `_tokens.scss`: update `--color-chart-info` and the per-brand docblock describing the success/info split. - `utils.ts`: mirror the new hex in `SEMANTIC_CHART_PALETTE.hyperdx.info` so the SSR/`getComputedStyle`-failure fallback matches the SCSS. - `agent_docs/data_viz_colors.md`: update the semantic palette description and the HyperDX per-theme bullets. Co-authored-by: Cursor --- agent_docs/data_viz_colors.md | 14 ++++++++----- .../app/src/theme/themes/hyperdx/_tokens.scss | 21 ++++++++++--------- packages/app/src/utils.ts | 2 +- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 180af9f846..0c1357bb12 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -98,9 +98,12 @@ categorical slot 0. ``` Defined in both `_tokens.scss` files. **Per-brand**: HyperDX uses brand green -for `success` and `info` (`#00c28a`); ClickStack uses Observable green +(`#00c28a`) for `success` and Observable cyan (`#6cc5b0`, same hue as the +categorical `chart-cyan`) for `info`; ClickStack uses Observable green (`#3ca951`) for `success` and brand blue (`#437eef`, same hue as the categorical -`chart-blue` and `--click-global-color-text-link-default`) for `info`. Warning +`chart-blue` and `--click-global-color-text-link-default`) for `info`. Both +brands' `info` reuses a categorical hue rather than a bespoke value, so +info-level series visually rhyme with the matching categorical slot. Warning and error are the same across themes. Unlike the categorical hues, **the semantic CSS vars are read at runtime** via @@ -369,9 +372,10 @@ place themes differ is the semantic chart layer. ### HyperDX -- `--color-chart-success` and `--color-chart-info` use brand green (`#00c28a`). -- Single-series charts that pick a "neutral" / info-level color via - `getChartColorInfo()` render brand green. +- `--color-chart-success` uses brand green (`#00c28a`). +- `--color-chart-info` uses Observable cyan (`#6cc5b0`, same as the categorical + `chart-cyan`), so info-level logs and `getChartColorInfo()` render cyan — + visually distinct from `success` (the two used to collapse to the same hex). - Multi-series charts start at brand blue (slot 0, `#437eef`) and proceed through the canonical palette — brand identity is preserved via the Mantine green accent, sidebar gradient, and semantic chart tokens. diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 946f23c03d..22aac50386 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -24,20 +24,21 @@ @include shared-chart.chart-categorical-tokens; /* - * Chart Semantic Colors - per-brand (HyperDX brand green for - * `success` and `info`). Unlike the categorical hues above, semantic - * tokens vary by brand theme, so `getChartColor{Success,Warning,...}` - * in `utils.ts` reads them via `getComputedStyle` and reacts to a - * runtime theme switch without a React re-render. Inline `var()` - * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. - * Within HyperDX, the dark and light schemes happen to use the same - * hexes, which is why they're inside `chart-tokens` rather than the - * scheme-specific mixins. + * Chart Semantic Colors - per-brand. HyperDX uses brand green + * (`#00c28a`) for `success` and Observable cyan (`#6cc5b0`, same + * hue as the categorical `chart-cyan`) for `info`. Unlike the + * categorical hues above, semantic tokens vary by brand theme, so + * `getChartColor{Success,Warning,...}` in `utils.ts` reads them via + * `getComputedStyle` and reacts to a runtime theme switch without a + * React re-render. Inline `var()` consumers (e.g. `LogLevel.tsx`) + * get the same benefit for free. Within HyperDX, the dark and light + * schemes happen to use the same hexes, which is why they're inside + * `chart-tokens` rather than the scheme-specific mixins. */ --color-chart-success: #00c28a; --color-chart-warning: #efb118; --color-chart-error: #ff725c; - --color-chart-info: #00c28a; + --color-chart-info: #6cc5b0; /* Chart Semantic Colors - Highlighted (for hover/selection states) */ --color-chart-success-highlight: #80d9b3; diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 1654a7be39..5b32bfd8dd 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -403,7 +403,7 @@ const SEMANTIC_CHART_PALETTE = { success: '#00c28a', warning: '#efb118', error: '#ff725c', - info: '#00c28a', + info: '#6cc5b0', successHighlight: '#80d9b3', warningHighlight: '#f5c94d', errorHighlight: '#ffa090', From f7eaa03bde64dc51c7364c39ca37bcd78032675c Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 11:05:03 +0100 Subject: [PATCH 11/17] docs(storybook): add per-brand info chart colors to Chart Colors story Surface `--color-chart-info` in the semantic swatch list (Brand toolbar aware) and add an Info Chart Colors By Brand story that compares HyperDX cyan (#6cc5b0) vs ClickStack blue (#437eef) side by side. Also fix the stale getChartColorInfo() JSDoc. Co-authored-by: Cursor --- .../app/src/theme/ChartColors.stories.tsx | 82 ++++++++++++++++++- packages/app/src/utils.ts | 2 +- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/app/src/theme/ChartColors.stories.tsx b/packages/app/src/theme/ChartColors.stories.tsx index 2eafb58c38..3f7a875c22 100644 --- a/packages/app/src/theme/ChartColors.stories.tsx +++ b/packages/app/src/theme/ChartColors.stories.tsx @@ -4,6 +4,7 @@ import { CATEGORICAL_PALETTE_TOKENS, COLORS, getChartColorError, + getChartColorInfo, getChartColorSuccess, getChartColorWarning, } from '@/utils'; @@ -46,8 +47,33 @@ const SEMANTIC_CHART_COLORS = [ hex: getChartColorError(), label: 'Error (Red)', }, + { + name: 'color-chart-info', + hex: getChartColorInfo(), + label: 'Info', + }, ]; +// Per-brand info colors. Unlike success/warning/error (same hex on both +// brands today), `--color-chart-info` varies by brand and reuses a +// categorical hue on each side (HyperDX → cyan, ClickStack → blue). +const INFO_CHART_COLORS_BY_BRAND = [ + { + brand: 'HyperDX', + name: 'color-chart-info', + hex: '#6cc5b0', + label: 'Info (Cyan)', + note: 'Observable cyan — same hue as categorical chart-cyan', + }, + { + brand: 'ClickStack', + name: 'color-chart-info', + hex: '#437eef', + label: 'Info (Blue)', + note: 'Brand blue — same hue as categorical chart-blue and link color', + }, +] as const; + const story = { title: 'Design Tokens/Chart Colors', }; @@ -126,6 +152,17 @@ export const AllChartColors = () => (

Semantic Chart Colors

+

+ Success, warning, and error are shared across brands. Info varies by brand + — use the Brand toolbar to preview the active theme, or see{' '} + Info Chart Colors By Brand for a side-by-side comparison. +

{SEMANTIC_CHART_COLORS.map(({ name, hex, label }) => ( @@ -323,7 +360,7 @@ export const SemanticColorsPreview = () => ( fontSize: 24, }} > - {i === 0 ? '98%' : i === 1 ? '15' : '3'} + {i === 0 ? '98%' : i === 1 ? '15' : i === 2 ? '3' : '42'}
{label}
@@ -335,6 +372,49 @@ export const SemanticColorsPreview = () => ( ); +export const InfoChartColorsByBrand = () => ( +
+

Info Chart Colors By Brand

+

+ `--color-chart-info` is the brand-primary semantic for info-level logs, + neutral single-series charts, and similar "default accent" + surfaces. Both brands reuse a categorical hue rather than a bespoke hex. +

+
+ {INFO_CHART_COLORS_BY_BRAND.map(({ brand, name, hex, label, note }) => ( +
+

{brand}

+ +

+ {note} +

+
+ ))} +
+
+); + export const AccessibilityCheck = () => (

Color Accessibility

diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 109cc58180..9532452468 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -610,7 +610,7 @@ export function getChartColorError(): string { } /** Primary brand color used for info-level logs and similar - * "neutral / default" series. HyperDX brand green, ClickStack blue. */ + * "neutral / default" series. HyperDX Observable cyan, ClickStack brand blue. */ export function getChartColorInfo(): string { return getSemanticChartColor('--color-chart-info', 'info'); } From a134bb643cc22cea3f695cf3a430987d9ac75609 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 11:07:48 +0100 Subject: [PATCH 12/17] docs(changeset): align info semantic notes with HyperDX cyan split Update the rename-chart-palette-tokens changeset and data_viz_colors Storybook reference so HyperDX `--color-chart-info` is documented as Observable cyan (#6cc5b0), distinct from success green, and the new InfoChartColorsByBrand story is listed. Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 10 ++++++---- agent_docs/data_viz_colors.md | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 66387932c7..1898ef984d 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -40,10 +40,12 @@ The categorical palette is based on Observable 10, with `chart-blue` swapped to (`--click-global-color-text-link-default`); all other hues are straight from Observable 10. The palette resolves identically on both themes — picking `chart-blue` always renders the brand blue. Brand identity for charts moves -entirely into the semantic layer: HyperDX info logs and `getChartColorInfo()` -resolve to brand green via the new `--color-chart-info` token, while ClickStack -info resolves to the same brand blue `#437eef` so info-level logs and primary -series stay visually aligned with the link color. +entirely into the semantic layer: HyperDX `--color-chart-info` resolves to +Observable cyan `#6cc5b0` (same hue as categorical `chart-cyan`), visually +distinct from `--color-chart-success` (brand green `#00c28a`). ClickStack info +resolves to brand blue `#437eef` (same hue as categorical `chart-blue` and the +link color), so info-level logs and primary series stay visually aligned with +the link color on that side. Internally, JS (`CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts`) is the source of truth for categorical hues — `getColorFromCSSVariable` and diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 0c1357bb12..9fdc355c26 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -258,8 +258,10 @@ semantic palettes: 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. +`SemanticColorsPreview` (includes info; responds to the Brand toolbar), +`InfoChartColorsByBrand` (HyperDX cyan vs ClickStack blue side by side), 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. From 6edb12bed9958292236c34b7e8b077fc19208ca4 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 12:18:54 +0100 Subject: [PATCH 13/17] refactor(theme): unify chart semantic tokens across HyperDX and ClickStack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--color-chart-success` and `--color-chart-info` now resolve to categorical `chart-green` (`#3ca951`) and `chart-blue` (`#437eef`) on both brands. Warning and error were already shared. With the categorical palette also unified, chart colors carry no brand identity — that role moves to non-chart UI chrome (Mantine accent, sidebar gradient, Click UI globals). - Move semantic chart tokens into a shared `chart-semantic-tokens` SCSS mixin in `_chart-categorical-tokens.scss` alongside the existing categorical mixin. Each theme's `chart-tokens` is now a 2-line `@include` of both shared mixins; the previously-duplicated semantic block is gone. Per-brand overrides remain possible by declaring the var after the shared `@include`. - `SEMANTIC_CHART_PALETTE.hyperdx.{success,info}` in `utils.ts` updated to `#3ca951` / `#437eef` to mirror the SCSS (ClickStack entries were already these hexes for success and info post-#2362). - Remove the redundant `InfoChartColorsByBrand` Storybook story and the per-brand info constants — both columns rendered identical swatches once info unified. Semantic preview now describes all four tokens as unified. - Update docs (`agent_docs/data_viz_colors.md`, changeset, JSDoc, inline comments) to reflect the unified semantic layer and shared-mixin layout. - Update `utils.test.ts` SSR-fallback expectation to `#3ca951`. HyperDX brand green (`#00c28a`) is unchanged for Mantine accent, buttons, and sidebar gradient — only chart `success` moved off it. Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 10 +- agent_docs/data_viz_colors.md | 95 +++++++++---------- packages/app/src/HDXMultiSeriesTimeChart.tsx | 4 +- packages/app/src/__tests__/utils.test.ts | 2 +- .../app/src/theme/ChartColors.stories.tsx | 72 +------------- .../themes/_chart-categorical-tokens.scss | 59 ++++++++---- .../src/theme/themes/clickstack/_tokens.scss | 30 +----- .../app/src/theme/themes/hyperdx/_tokens.scss | 29 +----- packages/app/src/utils.ts | 10 +- packages/common-utils/src/types.ts | 2 +- 10 files changed, 106 insertions(+), 207 deletions(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 1898ef984d..3d4845d770 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -40,12 +40,10 @@ The categorical palette is based on Observable 10, with `chart-blue` swapped to (`--click-global-color-text-link-default`); all other hues are straight from Observable 10. The palette resolves identically on both themes — picking `chart-blue` always renders the brand blue. Brand identity for charts moves -entirely into the semantic layer: HyperDX `--color-chart-info` resolves to -Observable cyan `#6cc5b0` (same hue as categorical `chart-cyan`), visually -distinct from `--color-chart-success` (brand green `#00c28a`). ClickStack info -resolves to brand blue `#437eef` (same hue as categorical `chart-blue` and the -link color), so info-level logs and primary series stay visually aligned with -the link color on that side. +entirely into the semantic layer: `--color-chart-success` and `--color-chart-info` +resolve to categorical `chart-green` (`#3ca951`) and `chart-blue` (`#437eef`) on +both HyperDX and ClickStack, so success fills, info-level logs, and the +matching multi-series slots all read consistently across brands. Internally, JS (`CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts`) is the source of truth for categorical hues — `getColorFromCSSVariable` and diff --git a/agent_docs/data_viz_colors.md b/agent_docs/data_viz_colors.md index 9fdc355c26..4f12ed08ea 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -68,22 +68,22 @@ The CSS vars exist for: `getColorFromCSSVariable`/`getColorFromCSSToken` back to reading the var and add per-brand entries to `CATEGORICAL_HEX_BY_TOKEN`. -The 10 categorical hues live in a single shared partial, -`packages/app/src/theme/themes/_chart-categorical-tokens.scss`, which both brand -themes `@use` and `@include` inside their per-theme `chart-tokens` mixin. Each -theme's `chart-tokens` mixin 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** for the -unified categorical layer (the shared partial) and one block per theme for the -per-brand semantic layer. **If you change a hex in the shared partial, change it -in `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts` too — the SCSS and JS sources are -intentionally mirrored.** - -Brand identity for charts is carried by the **semantic** tokens -(`--color-chart-success`, `-info`) and by non-chart UI chrome (Mantine accent, -sidebar gradient, Click UI globals), not by which hue happens to appear at -categorical slot 0. +The categorical hues and semantic chart colors live in a single shared partial, +`packages/app/src/theme/themes/_chart-categorical-tokens.scss` (`chart-categorical-tokens` +and `chart-semantic-tokens` mixins). Both brand themes `@use` it and `@include` +both mixins inside their per-theme `chart-tokens` mixin. Each theme's +`chart-tokens` mixin 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** for both layers. **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.** + +Brand identity for charts is carried by non-chart UI chrome (Mantine accent, +sidebar gradient, Click UI globals), not by per-brand chart semantic colors — +`success` and `info` reuse categorical `chart-green` and `chart-blue` on both +brands. ### Semantic chart colors (`--color-chart-{success|warning|error|info}`) @@ -97,14 +97,10 @@ categorical slot 0. --color-chart-error-highlight ``` -Defined in both `_tokens.scss` files. **Per-brand**: HyperDX uses brand green -(`#00c28a`) for `success` and Observable cyan (`#6cc5b0`, same hue as the -categorical `chart-cyan`) for `info`; ClickStack uses Observable green -(`#3ca951`) for `success` and brand blue (`#437eef`, same hue as the categorical -`chart-blue` and `--click-global-color-text-link-default`) for `info`. Both -brands' `info` reuses a categorical hue rather than a bespoke value, so -info-level series visually rhyme with the matching categorical slot. Warning -and error are the same across themes. +Defined in both `_tokens.scss` files. **Unified on both brands**: `success` +reuses categorical `chart-green` (`#3ca951`); `info` reuses categorical +`chart-blue` (`#437eef`). Warning and error use the same Observable hexes across +themes. Unlike the categorical hues, **the semantic CSS vars are read at runtime** via `getComputedStyle` (see `getSemanticChartColor` in `utils.ts`). That keeps @@ -258,10 +254,8 @@ semantic palettes: 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` (includes info; responds to the Brand toolbar), -`InfoChartColorsByBrand` (HyperDX cyan vs ClickStack blue side by side), and -`AccessibilityCheck`. Run storybook in the `app` package to inspect both schemes -side by side. +`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. @@ -369,18 +363,17 @@ const slices = sorted.map((d, i) => ({ ## Per-theme considerations -The categorical palette is identical on both themes — Observable 10. The only -place themes differ is the semantic chart layer. +The categorical palette is identical on both themes — Observable 10. Semantic +`success` and `info` also reuse categorical hues on both brands. ### HyperDX -- `--color-chart-success` uses brand green (`#00c28a`). -- `--color-chart-info` uses Observable cyan (`#6cc5b0`, same as the categorical - `chart-cyan`), so info-level logs and `getChartColorInfo()` render cyan — - visually distinct from `success` (the two used to collapse to the same hex). +- `--color-chart-success` uses categorical `chart-green` (`#3ca951`). +- `--color-chart-info` uses categorical `chart-blue` (`#437eef`), so info-level + logs and `getChartColorInfo()` render the same blue as multi-series slot 0. - Multi-series charts start at brand blue (slot 0, `#437eef`) and proceed through the canonical palette — brand identity is preserved via the Mantine - green accent, sidebar gradient, and semantic chart tokens. + green accent and sidebar gradient, not via chart semantic colors. ### ClickStack @@ -388,10 +381,10 @@ place themes differ is the semantic chart layer. 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. -- `--color-chart-success` uses Observable green (`#3ca951`). -- `--color-chart-info` uses brand blue (`#437eef`, same as the categorical - `chart-blue` and `--click-global-color-text-link-default`), so info-level logs - and `getChartColorInfo()` render the brand blue. +- `--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 the brand blue. ### Both themes @@ -422,12 +415,13 @@ preference: ### A new semantic color (e.g. `--color-chart-pending`) -1. Pick the hex per theme (HyperDX often uses brand variants; ClickStack uses - Observable variants). +1. Pick the hex (unified across brands unless you have a deliberate per-brand split). 2. Add `--color-chart-pending` and (if needed) `--color-chart-pending-highlight` - to the `@mixin chart-tokens` block in both theme files (HyperDX and - ClickStack `_tokens.scss`). The mixin is `@include`'d in each scheme - selector, so two edits cover dark and light for both brands. + to the `@mixin chart-semantic-tokens` block in + `_chart-categorical-tokens.scss`. The mixin is `@include`'d in each theme's + `chart-tokens` mixin, which is `@include`'d in each scheme selector — one edit + covers dark and light for both brands. For a per-brand override only, declare + the var in that brand's `chart-tokens` mixin after the shared `@include`. 3. Add `pending` (and optionally `pendingHighlight`) to `SEMANTIC_CHART_PALETTE.hyperdx` and `.clickstack` in `utils.ts`. 4. Append `'chart-pending'` to `CHART_PALETTE_TOKENS` (and @@ -476,8 +470,8 @@ Why each is wrong: ## Pre-merge checklist for chart-touching PRs -- [ ] Toggled HyperDX ↔ ClickStack theme — semantic colors (success / info) - change as expected; categorical palette stays identical. +- [ ] Toggled HyperDX ↔ ClickStack theme — semantic success and info stay + unified (chart-green / chart-blue); categorical palette stays identical. - [ ] 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 @@ -487,8 +481,8 @@ Why each is wrong: - [ ] 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 **all three** places - (the relevant theme's `chart-tokens` mixin in `_tokens.scss`, +- [ ] 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.{theme}` in `utils.ts`). - [ ] Storybook `Design Tokens / Chart Colors` still renders correctly. @@ -496,9 +490,8 @@ Why each is wrong: | What | Where | | --------------------------------------------------- | -------------------------------------------------------------------------------- | -| Shared categorical chart vars (10 hues) | `packages/app/src/theme/themes/_chart-categorical-tokens.scss` | -| HyperDX semantic chart vars + chart-tokens mixin | `packages/app/src/theme/themes/hyperdx/_tokens.scss` | -| ClickStack semantic chart vars + chart-tokens mixin | `packages/app/src/theme/themes/clickstack/_tokens.scss` | +| Shared chart vars (10 categorical hues + semantics) | `packages/app/src/theme/themes/_chart-categorical-tokens.scss` | +| Per-theme `chart-tokens` mixin (`@include` chain) | `packages/app/src/theme/themes/hyperdx/_tokens.scss`, `clickstack/_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` | diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 4b80da7a31..0ec61488ae 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -680,8 +680,8 @@ export const MemoChart = memo(function MemoChart({ as a baseline; semantic colors returned by the `getChartColor{Info,Success,Warning,Error}` helpers can also appear in `lineData[].color` (e.g. info-level log series - resolve to the brand-green `--color-chart-info` on HyperDX, - which is not part of `COLORS`). Union them here so the + resolve to `--color-chart-info`, chart blue `#437eef`, on both + brands, which matches categorical slot 0). Union them here so the referenced `url(#time-chart-lin-grad-…)` always exists. */} {Array.from( new Set([ diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index ded15fb059..5d8eeb68aa 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1245,7 +1245,7 @@ describe('getColorFromCSSToken', () => { // Defaults to HyperDX in jsdom because the document has no // theme-clickstack class. - expect(getColorFromCSSToken('chart-success')).toBe('#00c28a'); + expect(getColorFromCSSToken('chart-success')).toBe('#3ca951'); expect(getColorFromCSSToken('chart-warning')).toBe('#efb118'); expect(getColorFromCSSToken('chart-error')).toBe('#ff725c'); }); diff --git a/packages/app/src/theme/ChartColors.stories.tsx b/packages/app/src/theme/ChartColors.stories.tsx index 3f7a875c22..bdd50500bd 100644 --- a/packages/app/src/theme/ChartColors.stories.tsx +++ b/packages/app/src/theme/ChartColors.stories.tsx @@ -50,29 +50,9 @@ const SEMANTIC_CHART_COLORS = [ { name: 'color-chart-info', hex: getChartColorInfo(), - label: 'Info', - }, -]; - -// Per-brand info colors. Unlike success/warning/error (same hex on both -// brands today), `--color-chart-info` varies by brand and reuses a -// categorical hue on each side (HyperDX → cyan, ClickStack → blue). -const INFO_CHART_COLORS_BY_BRAND = [ - { - brand: 'HyperDX', - name: 'color-chart-info', - hex: '#6cc5b0', - label: 'Info (Cyan)', - note: 'Observable cyan — same hue as categorical chart-cyan', - }, - { - brand: 'ClickStack', - name: 'color-chart-info', - hex: '#437eef', label: 'Info (Blue)', - note: 'Brand blue — same hue as categorical chart-blue and link color', }, -] as const; +]; const story = { title: 'Design Tokens/Chart Colors', @@ -159,9 +139,10 @@ export const AllChartColors = () => ( fontSize: 13, }} > - Success, warning, and error are shared across brands. Info varies by brand - — use the Brand toolbar to preview the active theme, or see{' '} - Info Chart Colors By Brand for a side-by-side comparison. + 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.

{SEMANTIC_CHART_COLORS.map(({ name, hex, label }) => ( @@ -372,49 +353,6 @@ export const SemanticColorsPreview = () => (
); -export const InfoChartColorsByBrand = () => ( -
-

Info Chart Colors By Brand

-

- `--color-chart-info` is the brand-primary semantic for info-level logs, - neutral single-series charts, and similar "default accent" - surfaces. Both brands reuse a categorical hue rather than a bespoke hex. -

-
- {INFO_CHART_COLORS_BY_BRAND.map(({ brand, name, hex, label, note }) => ( -
-

{brand}

- -

- {note} -

-
- ))} -
-
-); - export const AccessibilityCheck = () => (

Color Accessibility

diff --git a/packages/app/src/theme/themes/_chart-categorical-tokens.scss b/packages/app/src/theme/themes/_chart-categorical-tokens.scss index 0678ee6d0d..0f937f9394 100644 --- a/packages/app/src/theme/themes/_chart-categorical-tokens.scss +++ b/packages/app/src/theme/themes/_chart-categorical-tokens.scss @@ -1,30 +1,30 @@ -/* Shared Chart Categorical Tokens - used by both theme files */ +/* Shared Chart Tokens - used by both theme files */ /* - * The 10-hue categorical chart palette is genuinely unified across - * brands (HyperDX and ClickStack both resolve `--color-chart-{hue}` - * to the same hex). This file is the SCSS source of truth for those - * hues; both theme files `@use` it and `@include` the mixin inside - * their per-theme `chart-tokens` mixin. + * 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 chart identity (success, info, warning, error) lives in - * each theme's `chart-tokens` mixin alongside this `@include`. Those - * semantic tokens genuinely vary per brand, so they don't belong here. + * 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. * - * The React rendering path reads these hues from - * `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` directly - * (`getColorFromCSSVariable` / `getColorFromCSSToken` skip - * `getComputedStyle` for categorical tokens) — the palette is the - * same on every theme, so the round-trip would add a layout read per - * series for no functional gain. These vars exist so SCSS modules - * and inline `var()` consumers can reference hues without importing - * from JS, and for devtools inspection. If you change a hex here, - * update `CATEGORICAL_HEX_BY_TOKEN` in `packages/app/src/utils.ts` - * to match — the SCSS and JS sources are intentionally mirrored. + * 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 + * `--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 { @@ -39,3 +39,20 @@ --color-chart-brown: #9c6b4e; --color-chart-gray: #9498a0; } + +/* + * Semantic chart colors — unified on both brands. `success` and `info` alias + * categorical `chart-green` and `chart-blue`; warning and error use shared + * Observable hexes. Must be `@include`d after `chart-categorical-tokens`. + */ +@mixin chart-semantic-tokens { + --color-chart-success: var(--color-chart-green); + --color-chart-warning: #efb118; + --color-chart-error: #ff725c; + --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 823331c326..f079c14bee 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -10,39 +10,15 @@ * @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. The categorical hues themselves come from the shared - * `chart-categorical-tokens` partial, since they're unified across - * brands; only the per-brand semantic tokens live in this file. + * 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 { - /* Categorical Palette - shared with HyperDX (10 unified hues). */ @include shared-chart.chart-categorical-tokens; - - /* - * Chart Semantic Colors - per-brand. Unlike the categorical hues - * above, these vary by theme, so `getChartColor{Success,Warning,...}` - * in `utils.ts` reads them via `getComputedStyle` and reacts to a - * runtime theme switch without a React re-render. Inline `var()` - * consumers (e.g. `LogLevel.tsx`) get the same benefit for free. - * ClickStack uses the brand blue (#437eef, same hue as - * `--click-global-color-text-link-default` and the categorical - * `chart-blue`) for `info` and Observable green for `success`. - * Within ClickStack, the dark and light schemes happen to use the - * same hexes, which is why they're inside `chart-tokens` rather - * than the scheme-specific blocks. - */ - --color-chart-success: #3ca951; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #437eef; - - /* Chart Semantic Colors - Highlighted (for hover/selection states) */ - --color-chart-success-highlight: #80d9b3; - --color-chart-warning-highlight: #f5c94d; - --color-chart-error-highlight: #ffa090; + @include shared-chart.chart-semantic-tokens; } /* Dark Mode */ diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 22aac50386..6549aee8c6 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -12,38 +12,15 @@ * @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. The categorical hues themselves come from the shared - * `chart-categorical-tokens` partial, since they're unified across - * brands; only the per-brand semantic tokens live in this file. + * 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 { - /* Categorical Palette - shared with ClickStack (10 unified hues). */ @include shared-chart.chart-categorical-tokens; - - /* - * Chart Semantic Colors - per-brand. HyperDX uses brand green - * (`#00c28a`) for `success` and Observable cyan (`#6cc5b0`, same - * hue as the categorical `chart-cyan`) for `info`. Unlike the - * categorical hues above, semantic tokens vary by brand theme, so - * `getChartColor{Success,Warning,...}` in `utils.ts` reads them via - * `getComputedStyle` and reacts to a runtime theme switch without a - * React re-render. Inline `var()` consumers (e.g. `LogLevel.tsx`) - * get the same benefit for free. Within HyperDX, the dark and light - * schemes happen to use the same hexes, which is why they're inside - * `chart-tokens` rather than the scheme-specific mixins. - */ - --color-chart-success: #00c28a; - --color-chart-warning: #efb118; - --color-chart-error: #ff725c; - --color-chart-info: #6cc5b0; - - /* Chart Semantic Colors - Highlighted (for hover/selection states) */ - --color-chart-success-highlight: #80d9b3; - --color-chart-warning-highlight: #f5c94d; - --color-chart-error-highlight: #ffa090; + @include shared-chart.chart-semantic-tokens; } @mixin dark-mode-tokens { diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 9532452468..6527c02cdf 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -396,14 +396,14 @@ const CATEGORICAL_HEX_BY_TOKEN = { * 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 each theme's `_tokens.scss`. + * in `_chart-categorical-tokens.scss` (`chart-semantic-tokens` mixin). */ const SEMANTIC_CHART_PALETTE = { hyperdx: { - success: '#00c28a', + success: '#3ca951', warning: '#efb118', error: '#ff725c', - info: '#6cc5b0', + info: '#437eef', successHighlight: '#80d9b3', warningHighlight: '#f5c94d', errorHighlight: '#ffa090', @@ -609,8 +609,8 @@ export function getChartColorError(): string { return getSemanticChartColor('--color-chart-error', 'error'); } -/** Primary brand color used for info-level logs and similar - * "neutral / default" series. HyperDX Observable cyan, ClickStack brand blue. */ +/** 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'); } diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index ed7828cea2..b2787dfe4d 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -796,7 +796,7 @@ export function isOnClickDashboardById( * Tokens map to CSS variables in * `packages/app/src/theme/themes//_tokens.scss`: * chart-{hue} -> --color-chart-{hue} (10 hues, unified across themes) - * chart-success/warning/error -> --color-chart-{success|warning|error} (per-brand semantic) + * chart-success/warning/error/info -> --color-chart-{success|warning|error|info} (semantic; unified across brands) * * Storing tokens (not hex) lets user choices reflow correctly across * themes and color modes; see notes/repo-conventions/hyperdx/tile-styling.md. From 619e89f1998369e8e6c64ac035ff5ede1fc2c5f1 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 14:08:34 +0100 Subject: [PATCH 14/17] fix(theme): address deep-review findings on chart palette rename Address all P0/P1, P2, and P3 items from the deep review on #2362. P0/P1: - DBDashboardImportPage now normalizes legacy chart-N tile colors before the strict DashboardTemplateSchema.safeParse, so templates exported from a pre-rename deploy import without an opaque enum error. P2: - Dashboards POST/PATCH routes mount a migrateLegacyDashboardTileColors middleware that rewrites legacy chart-N before validateRequest runs ChartPaletteTokenSchema. Catches non-React clients (stale-bundle tabs during rolling deploys, CI scripts, MCP, the upcoming external-API parity work) for a one-release deprecation window without weakening z.input/z.output equality. Same shim applied to the on-disk dashboard provisioner task. - Remove duplicate useDashboards/useCreateDashboard/useUpdateDashboard/ useDeleteDashboard from packages/app/src/api.ts; Spotlights.tsx now reaches for the canonical normalizer-guarded useDashboards in packages/app/src/dashboard.ts. Closes the stale React Query cache race surface. - Add mutation-level tests for useCreateDashboard / useUpdateDashboard that capture the mutationFn and assert hue tokens in the outbound hdxServer body. Plus standalone normalizeRawDashboardTileColors tests covering the JSON-import walker. Plus a server-side integration test for the dashboards-router migration shim. - Tighten CATEGORICAL_PALETTE_TOKENS / SEMANTIC_PALETTE_TOKENS to tuple literals (were `readonly ChartPaletteToken[]`, leaked semantic tokens into the categorical subset). Introduce CategoricalChartPaletteToken and add a forward `satisfies Record<...>` + reverse completeness check on CATEGORICAL_HEX_BY_TOKEN so any future drift between the JS hex map and the shared enum becomes a compile error. Also drops the manual `as keyof typeof` cast in the COLORS map. - Extract collectMemoChartGradientHexes from the MemoChart defs JSX and unit-test it: categorical baseline, semantic hex union, dedup, and the undefined-filter guard. Recharts renders awkwardly in jsdom at the container-sized SVG layer, so the helper is the pinnable contract. - Add inline pointer next to LEGACY_CHART_PALETTE_TOKEN_MAP for the ClickStack slot flip (already in changeset + agent_docs). - Append color-chart-info and color-chart-success-highlight to semanticColorsGrouped.ts so designers see them in the inventory. - Replace getChartColorInfo() / getChartColorError() in ChartUtils test expectations with concrete hexes (#437eef, #ff725c) so a helper regression can't pass-by-mirroring. P3: - CHART_PALETTE_TOKENS docstring updated to clarify chart-info is a render-time CSS var, not a picker enum value. - semanticTokenFallback parameter narrowed to non-categorical tokens and the default branch now does an exhaustive `never` assertion (was returning COLORS[0] from an unreachable path). - Collapse SEMANTIC_CHART_PALETTE to a SemanticChartHexes typed shape so both brand entries are pinned to parity through the type system. - Alias --color-chart-warning / --color-chart-error through their matching categorical hue vars (var(--color-chart-orange) / var(--color-chart-red)) for symmetry with success/info. - fetchDashboards export comment rewritten to clarify it's the shared queryFn (not test-only). - LEGACY_CHART_PALETTE_TOKEN_MAP keyed by a narrow LegacyChartPalette TokenKey union so a typo in a legacy slot at edit time is a compile error. - Drop the double cast `tile.config as { color?: unknown }` in normalizeDashboardTileColors now that SavedChartConfig types color properly. - Symmetric normalization on the URL-state local setDashboard read + write paths. - Parameterize the legacy slot test across all 10 slots in both the local and remote dashboard suites. - Strip unresolvable color tokens (stale hexes, hand-edited values, future-rolled-back tokens) at the normalizer boundary so they don't sail into the strict server-side schema and 400 the next save. Validated: lint clean across app/api/common-utils; tsc --noEmit clean across app/api/common-utils; common-utils unit (1163/1163), app unit (1793 passed / 9 skipped). The new dashboard-router migration test type-checks and needs Docker (`make dev-int FILE=dashboard`) to run. Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 38 ++- .../routers/api/__tests__/dashboard.test.ts | 44 ++++ packages/api/src/routers/api/dashboards.ts | 38 +++ .../src/tasks/provisionDashboards/index.ts | 24 ++ packages/app/src/DBDashboardImportPage.tsx | 9 + packages/app/src/HDXMultiSeriesTimeChart.tsx | 35 ++- packages/app/src/Spotlights.tsx | 4 +- packages/app/src/__tests__/ChartUtils.test.ts | 16 +- .../__tests__/HDXMultiSeriesTimeChart.test.ts | 55 +++++ .../app/src/__tests__/Spotlights.test.tsx | 6 +- .../src/__tests__/dashboard.remote.test.ts | 216 ++++++++++++++++-- packages/app/src/__tests__/dashboard.test.ts | 50 ++-- packages/app/src/api.ts | 65 +----- packages/app/src/dashboard.ts | 82 ++++++- .../app/src/theme/semanticColorsGrouped.ts | 2 + .../themes/_chart-categorical-tokens.scss | 14 +- packages/app/src/utils.ts | 88 +++++-- packages/common-utils/src/types.ts | 58 +++-- 18 files changed, 668 insertions(+), 176 deletions(-) create mode 100644 packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 3d4845d770..83c7355b60 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -1,6 +1,7 @@ --- '@hyperdx/common-utils': minor '@hyperdx/app': minor +'@hyperdx/api': patch --- refactor(theme): rename chart palette tokens from chart-1..10 to hue-named @@ -12,16 +13,33 @@ Stored configs from the initial color picker (#2265) keep working. matches `z.output` — wrapping it in `z.preprocess` would poison `validateRequest`'s `req.body` inference all the way up to `Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` -happens in two complementary places: a `normalizeDashboardTileColors` helper (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 server-side schema. A -render-time `resolveChartPaletteToken` helper (in `common-utils/types.ts`, -re-exported from `packages/app/src/utils.ts`) acts as belt-and-suspenders inside -`DBNumberChart` and `ColorSwatchInput` for tiles constructed in memory between -fetch and save. Both paths preserve the HyperDX slot ordering from #2265 (slot 1 -= brand green, slot 2 = blue, etc.). +happens at four complementary points so no entry path can slip through: + +- **Fetch-time / write-time (React)**: `normalizeDashboardTileColors` in + `packages/app/src/dashboard.ts` heals dashboards on read + (`useDashboards` / `fetchLocalDashboards` / `fetchDashboards`) and on write + (`useUpdateDashboard` / `useCreateDashboard`). Unresolvable color strings + (stale hexes, hand-edited values) are stripped at the boundary so they + never reach the strict server-side schema and 400 the next save. +- **JSON import**: `DBDashboardImportPage` runs + `normalizeRawDashboardTileColors` on the parsed JSON *before* the strict + `DashboardTemplateSchema.safeParse`, so templates exported from a + pre-rename deploy import cleanly. +- **Server-side migration shim**: the dashboards POST / PATCH routes mount + a request-body preprocessor that rewrites legacy tile colors before + `validateRequest` runs `ChartPaletteTokenSchema`. Catches non-React + HTTP callers (stale-bundle tabs during a rolling deploy, CI scripts, + MCP, the upcoming external-API parity work) for a one-release + deprecation window without weakening the schema's input/output equality. + The dashboard provisioner task applies the same shim before parsing + on-disk template files. +- **Render-time (belt-and-suspenders)**: `DBNumberChart` and + `ColorSwatchInput` also call `resolveChartPaletteToken` for tiles + constructed in memory between fetch and save (`ChartEditor` form + state, unit-test fixtures, hand-rolled `Tile` literals). + +The migration preserves the HyperDX slot ordering from #2265 (slot 1 = brand +green, slot 2 = blue, etc.). **ClickStack legacy color caveat:** Pre-rename ClickStack used a different slot ordering than HyperDX (`--color-chart-1` was brand blue `#437eef`, not brand diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 415c95aa9d..97c9a7d6a5 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -80,6 +80,50 @@ describe('dashboard router', () => { ); }); + // Server-side migration shim for legacy `chart-1`..`chart-10` tokens + // shipped by #2265. Stale-bundle React clients during a rolling deploy + // and non-React HTTP callers (CI scripts, MCP, future external API + // writes) can still send the numeric tokens; the route normalizes + // them to hue-named equivalents *before* the strict + // `ChartPaletteTokenSchema` runs, otherwise the request would 400. + it('migrates legacy chart-N tile colors on POST', async () => { + const tileWithLegacyColor = makeTile(); + tileWithLegacyColor.config = { + ...tileWithLegacyColor.config, + color: 'chart-1' as any, + }; + + const created = await agent + .post('/dashboards') + .send({ + name: 'Legacy Color Dashboard', + tiles: [tileWithLegacyColor], + tags: [], + }) + .expect(200); + + expect(created.body.tiles[0].config.color).toBe('chart-green'); + }); + + it('migrates legacy chart-N tile colors on PATCH', async () => { + const created = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + + const patchedTile = { + ...created.body.tiles[0], + config: { ...created.body.tiles[0].config, color: 'chart-10' }, + }; + + const updated = await agent + .patch(`/dashboards/${created.body.id}`) + .send({ tiles: [patchedTile, ...created.body.tiles.slice(1)] }) + .expect(200); + + expect(updated.body.tiles[0].config.color).toBe('chart-gray'); + }); + it('sets createdBy and updatedBy on create and populates them in GET', async () => { const created = await agent .post('/dashboards') diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index 8ac2ea2f30..f53cb1b685 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -3,6 +3,7 @@ import { DashboardWithoutIdSchema, PresetDashboard, PresetDashboardFilterSchema, + resolveChartPaletteToken, } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; @@ -29,6 +30,41 @@ import { objectIdSchema } from '@/utils/zod'; // create routes that will get and update dashboards const router = express.Router(); +/** + * Heal legacy `chart-1`..`chart-10` tile colors from #2265 on the request + * body *before* `validateRequest` runs `ChartPaletteTokenSchema`. Keeps the + * schema strict (so `z.input` == `z.output` and `req.body` infers cleanly) + * while still accepting payloads from any non-React HTTP client whose + * stored values haven't yet been healed by the app-side normalizer + * (`normalizeDashboardTileColors` in `packages/app/src/dashboard.ts`). + * + * This is a one-release deprecation shim — once stored data has converged + * on the hue-named tokens, it can be removed in favor of straight-strict + * validation. + */ +const migrateLegacyDashboardTileColors: express.RequestHandler = ( + req, + _res, + next, +) => { + const body: unknown = req.body; + if (!body || typeof body !== 'object') return next(); + const tiles = (body as { tiles?: unknown }).tiles; + if (!Array.isArray(tiles)) return next(); + for (const tile of tiles) { + if (!tile || typeof tile !== 'object') continue; + const config = (tile as { config?: unknown }).config; + if (!config || typeof config !== 'object') continue; + const c = config as { color?: unknown }; + if (typeof c.color !== 'string') continue; + const resolved = resolveChartPaletteToken(c.color); + if (resolved !== undefined && resolved !== c.color) { + c.color = resolved; + } + } + next(); +}; + router.get('/', async (req, res, next) => { try { const { teamId } = getNonNullUserWithTeam(req); @@ -43,6 +79,7 @@ router.get('/', async (req, res, next) => { router.post( '/', + migrateLegacyDashboardTileColors, validateRequest({ body: DashboardWithoutIdSchema, }), @@ -63,6 +100,7 @@ router.post( router.patch( '/:id', + migrateLegacyDashboardTileColors, validateRequest({ params: z.object({ id: objectIdSchema, diff --git a/packages/api/src/tasks/provisionDashboards/index.ts b/packages/api/src/tasks/provisionDashboards/index.ts index eb8c096792..e53427c2d1 100644 --- a/packages/api/src/tasks/provisionDashboards/index.ts +++ b/packages/api/src/tasks/provisionDashboards/index.ts @@ -1,6 +1,7 @@ import { DashboardWithoutId, DashboardWithoutIdSchema, + resolveChartPaletteToken, } from '@hyperdx/common-utils/dist/types'; import fs from 'fs'; import path from 'path'; @@ -12,6 +13,28 @@ import type { HdxTask } from '@/tasks/types'; import { ProvisionDashboardsTaskArgs } from '@/tasks/types'; import logger from '@/utils/logger'; +// Walks parsed-but-unvalidated JSON and rewrites any legacy +// `chart-1`..`chart-10` tile colors from #2265 to their hue-named +// equivalents. Mirrors `normalizeRawDashboardTileColors` in the app +// package; the provisioner can't import from `packages/app`, so the +// minimal walker is inlined here. +function migrateLegacyDashboardTileColorsInPlace(raw: unknown): void { + if (!raw || typeof raw !== 'object') return; + const tiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(tiles)) return; + for (const tile of tiles) { + if (!tile || typeof tile !== 'object') continue; + const config = (tile as { config?: unknown }).config; + if (!config || typeof config !== 'object') continue; + const c = config as { color?: unknown }; + if (typeof c.color !== 'string') continue; + const resolved = resolveChartPaletteToken(c.color); + if (resolved !== undefined && resolved !== c.color) { + c.color = resolved; + } + } +} + export function readDashboardFiles(dir: string): DashboardWithoutId[] { let files: string[]; try { @@ -25,6 +48,7 @@ export function readDashboardFiles(dir: string): DashboardWithoutId[] { for (const file of files) { try { const raw = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + migrateLegacyDashboardTileColorsInPlace(raw); const parsed = DashboardWithoutIdSchema.safeParse({ tags: [], ...raw, diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 9ce75353a5..b4438a9c55 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -57,6 +57,7 @@ import { useBrandDisplayName } from './theme/ThemeProvider'; import api from './api'; import { useConnections } from './connection'; import { + normalizeRawDashboardTileColors, useCreateDashboard, useDashboards, useUpdateDashboard, @@ -102,6 +103,14 @@ function FileSelection({ return; } + // Heal legacy `chart-1`..`chart-10` tile colors from #2265 *before* + // the strict `DashboardTemplateSchema.safeParse` runs. The schema's + // `ChartPaletteTokenSchema` is a plain `z.enum` (no `z.preprocess`), + // so a template exported from a pre-rename deploy would otherwise + // fail import with an opaque enum error and never reach the + // write-time normalizer in `useCreateDashboard`. + data = normalizeRawDashboardTileColors(data); + const result = DashboardTemplateSchema.safeParse(data); if (!result.success) { onComplete(null); diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 0ec61488ae..77b9560461 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -333,6 +333,32 @@ const StackedBarWithOverlap = (props: BarProps) => { ); }; +/** + * Compute the unique set of hexes referenced by `` defs + * inside MemoChart. Exported so a unit test can pin the dedup-and-union + * behavior without standing up a full recharts render (which jsdom + * struggles with at the container-sized SVG layer). + * + * Includes every categorical hex up front so any positional `` + * fill resolves, then unions in semantic hexes returned by the + * `getChartColor{Info,Success,Warning,Error}` helpers — those land in + * `lineData[].color` and would otherwise be missing a matching def. + * `undefined` colors are filtered so `c.replace('#', '')` can't throw + * on a future caller that leaves a series color unset. + */ +export function collectMemoChartGradientHexes( + lineData: { color?: string }[], +): string[] { + return Array.from( + new Set([ + ...COLORS, + ...lineData + .map(ld => ld.color) + .filter((c): c is string => typeof c === 'string'), + ]), + ); +} + export const MemoChart = memo(function MemoChart({ graphResults, setIsClickActive, @@ -683,14 +709,7 @@ export const MemoChart = memo(function MemoChart({ resolve to `--color-chart-info`, chart blue `#437eef`, on both brands, which matches categorical slot 0). Union them here so the referenced `url(#time-chart-lin-grad-…)` always exists. */} - {Array.from( - new Set([ - ...COLORS, - ...lineData - .map(ld => ld.color) - .filter((c): c is string => typeof c === 'string'), - ]), - ).map(c => { + {collectMemoChartGradientHexes(lineData).map(c => { return ( { const logomark = useLogomark({ size: 16 }); const { data: logViewsData } = useSavedSearches(); - const { data: dashboardsData } = api.useDashboards(); + const { data: dashboardsData } = useDashboards(); const actions = React.useMemo(() => { const logViews = logViewsData ?? []; diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index 851ad7ab50..2d3d5834ed 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -11,7 +11,15 @@ import { formatResponseForPieChart, formatResponseForTimeChart, } from '@/ChartUtils'; -import { COLORS, getChartColorError, getChartColorInfo } from '@/utils'; +import { COLORS } from '@/utils'; + +// Anchor info/error to concrete hexes rather than `getChartColorInfo()` / +// `getChartColorError()` so a regression that breaks the helpers can't +// move expected and actual in lockstep. Keep in sync with +// `_chart-categorical-tokens.scss` (`chart-semantic-tokens` mixin) and +// `SEMANTIC_CHART_PALETTE` in `packages/app/src/utils.ts`. +const SEMANTIC_INFO_HEX = '#437eef'; +const SEMANTIC_ERROR_HEX = '#ff725c'; describe('ChartUtils', () => { describe('formatResponseForTimeChart', () => { @@ -306,7 +314,7 @@ describe('ChartUtils', () => { expect(actual.lineData).toEqual([ { - color: getChartColorInfo(), + color: SEMANTIC_INFO_HEX, dataKey: 'info', currentPeriodKey: 'info', previousPeriodKey: 'info (previous)', @@ -315,7 +323,7 @@ describe('ChartUtils', () => { isDashed: false, }, { - color: getChartColorInfo(), + color: SEMANTIC_INFO_HEX, dataKey: 'debug', currentPeriodKey: 'debug', previousPeriodKey: 'debug (previous)', @@ -324,7 +332,7 @@ describe('ChartUtils', () => { isDashed: false, }, { - color: getChartColorError(), + color: SEMANTIC_ERROR_HEX, dataKey: 'error', currentPeriodKey: 'error', previousPeriodKey: 'error (previous)', diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts new file mode 100644 index 0000000000..aff4692293 --- /dev/null +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -0,0 +1,55 @@ +// Targeted unit test for the `` defs union that backs +// `` lookups inside `MemoChart`. The +// helper is exported so we can pin the union/dedup behavior without +// rendering recharts in jsdom (which struggles with sized SVG +// containers). Covers the regression flagged in the deep review on +// #2362 where a semantic-hex `lineData[].color` (e.g. the output of +// `getChartColorInfo()` on HyperDX) would not have a matching gradient +// def after the `COLORS` palette was unified to Observable 10. +import { collectMemoChartGradientHexes } from '../HDXMultiSeriesTimeChart'; +import { COLORS } from '../utils'; + +describe('collectMemoChartGradientHexes', () => { + it('includes every categorical hex from COLORS up front', () => { + const hexes = collectMemoChartGradientHexes([]); + for (const c of COLORS) { + expect(hexes).toContain(c); + } + expect(hexes).toHaveLength(COLORS.length); + }); + + it('unions in semantic hexes that lineData[].color introduces', () => { + // `#00c28a` is HyperDX brand green — historically returned by + // `getChartColorInfo()` for info-level series. After unifying + // `COLORS` to Observable 10 it is no longer in the categorical + // palette, so the gradient def must come from the lineData union. + const semanticHex = '#00c28a'; + const hexes = collectMemoChartGradientHexes([{ color: semanticHex }]); + expect(hexes).toContain(semanticHex); + expect(hexes).toHaveLength(COLORS.length + 1); + }); + + it('dedupes a lineData hex that is already in COLORS', () => { + const dup = COLORS[0]; + const hexes = collectMemoChartGradientHexes([ + { color: dup }, + { color: dup }, + ]); + expect(hexes.filter(h => h === dup)).toHaveLength(1); + expect(hexes).toHaveLength(COLORS.length); + }); + + it('filters out undefined and non-string colors', () => { + // The downstream `c.replace('#', '')` would throw if `undefined` + // sneaked through; this guards the defensive filter from being + // accidentally removed. + const hexes = collectMemoChartGradientHexes([ + { color: undefined }, + { color: '#abcdef' }, + // @ts-expect-error — intentionally exercising the runtime guard. + { color: 42 }, + ]); + expect(hexes).toContain('#abcdef'); + expect(hexes.every(h => typeof h === 'string')).toBe(true); + }); +}); diff --git a/packages/app/src/__tests__/Spotlights.test.tsx b/packages/app/src/__tests__/Spotlights.test.tsx index f796298e5d..7e7142cf67 100644 --- a/packages/app/src/__tests__/Spotlights.test.tsx +++ b/packages/app/src/__tests__/Spotlights.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; -import api from '../api'; +import { useDashboards } from '../dashboard'; import { useSavedSearches } from '../savedSearch'; import { useSpotlightActions } from '../Spotlights'; @@ -22,10 +22,10 @@ jest.mock('next/router', () => ({ }, })); jest.mock('../savedSearch'); -jest.mock('../api'); +jest.mock('../dashboard'); const mockUseSavedSearches = useSavedSearches as jest.Mock; -const mockUseDashboards = api.useDashboards as jest.Mock; +const mockUseDashboards = useDashboards as jest.Mock; describe('useSpotlightActions', () => { const mockSavedSearches = { diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts index 44d96c0089..cd2f17453a 100644 --- a/packages/app/src/__tests__/dashboard.remote.test.ts +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -14,15 +14,29 @@ jest.mock('nuqs', () => ({ parseAsJson: jest.fn(), useQueryState: jest.fn(), })); + +// Capture each mutation's `mutationFn` so the tests can invoke it directly +// without standing up a full React Query provider. Each `useMutation()` +// call appends its config to `mutationFnCalls`; tests pull the most +// recently registered fn and invoke it as if `mutate({...})` had run. +const mutationFnCalls: Array<(input: any) => any> = []; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), - useMutation: jest.fn(), - useQueryClient: jest.fn(), + useMutation: jest.fn((cfg: { mutationFn: (input: any) => any }) => { + mutationFnCalls.push(cfg.mutationFn); + return { mutate: jest.fn(), mutateAsync: jest.fn() }; + }), + useQueryClient: jest.fn(() => ({ invalidateQueries: jest.fn() })), })); jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) })); import { hdxServer } from '../api'; -import { fetchDashboards } from '../dashboard'; +import { + fetchDashboards, + normalizeRawDashboardTileColors, + useCreateDashboard, + useUpdateDashboard, +} from '../dashboard'; const hdxServerMock = hdxServer as jest.Mock; @@ -43,6 +57,7 @@ const setRemotePayload = (payload: unknown) => { beforeEach(() => { hdxServerMock.mockReset(); + mutationFnCalls.length = 0; }); describe('fetchDashboards (remote path)', () => { @@ -52,21 +67,24 @@ describe('fetchDashboards (remote path)', () => { // downstream consumers see the canonical hue tokens that // `ChartPaletteTokenSchema` accepts. Symmetric coverage with the // local-path suite in `dashboard.test.ts`. - it('migrates legacy chart-1..10 tokens from a remote payload', async () => { - setRemotePayload(remoteDashboardWithTileColor('chart-1')); + it.each([ + ['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'], + ])('migrates legacy %s → %s from a remote payload', async (legacy, hue) => { + setRemotePayload(remoteDashboardWithTileColor(legacy)); const result = await fetchDashboards(); expect(hdxServerMock).toHaveBeenCalledWith('dashboards'); - expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-green' }); - }); - - it('migrates chart-10 to chart-gray on the remote path', async () => { - setRemotePayload(remoteDashboardWithTileColor('chart-10')); - - const result = await fetchDashboards(); - - expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-gray' }); + expect(result[0].tiles[0].config).toMatchObject({ color: hue }); }); it('passes through hue-named tokens unchanged', async () => { @@ -77,14 +95,12 @@ describe('fetchDashboards (remote path)', () => { expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-orange' }); }); - it('preserves unknown color strings (no silent data loss)', async () => { + it('strips unresolvable color strings so they never reach the strict schema', async () => { setRemotePayload(remoteDashboardWithTileColor('chart-future-magenta')); const result = await fetchDashboards(); - expect(result[0].tiles[0].config).toMatchObject({ - color: 'chart-future-magenta', - }); + expect(result[0].tiles[0].config).not.toHaveProperty('color'); }); it('does not touch tiles whose config has no color field', async () => { @@ -104,3 +120,167 @@ describe('fetchDashboards (remote path)', () => { expect(result[0].tiles[0].config).toEqual({ displayType: 1 }); }); }); + +// Symmetric write-time coverage: dashboards constructed outside the +// fetch path (JSON import, presets, MCP payloads) hit +// `useCreateDashboard` / `useUpdateDashboard` directly. The strict +// server-side `ChartPaletteTokenSchema` would 400 a legacy `chart-N` +// here, so the mutations also call `normalizeDashboardTileColors` +// before serializing the body. These tests pin that contract. +describe('useCreateDashboard / useUpdateDashboard write-time normalization', () => { + const captureMutation = ( + hookFactory: () => unknown, + ): ((input: any) => any) => { + hookFactory(); + const fn = mutationFnCalls[mutationFnCalls.length - 1]; + expect(fn).toBeDefined(); + return fn; + }; + + beforeEach(() => { + // hdxServer is invoked as `hdxServer(url, opts).json()` for POST + // but as `hdxServer(url, opts)` for PATCH; mock to handle both. + hdxServerMock.mockReturnValue({ + json: jest.fn().mockResolvedValue({ id: 'a' }), + }); + }); + + it('rewrites legacy chart-N to hue tokens before POST in useCreateDashboard', async () => { + const create = captureMutation(useCreateDashboard); + + await create({ + name: 'D', + tiles: [ + { id: 't1', x: 0, y: 0, w: 4, h: 4, config: { color: 'chart-1' } }, + ], + tags: [], + }); + + expect(hdxServerMock).toHaveBeenCalledWith( + 'dashboards', + expect.objectContaining({ + method: 'POST', + json: expect.objectContaining({ + tiles: [ + expect.objectContaining({ config: { color: 'chart-green' } }), + ], + }), + }), + ); + }); + + it('strips unresolvable color before POST in useCreateDashboard', async () => { + const create = captureMutation(useCreateDashboard); + + await create({ + name: 'D', + tiles: [ + { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-future-magenta' }, + }, + ], + tags: [], + }); + + const call = hdxServerMock.mock.calls[0]; + const body = call[1].json; + expect(body.tiles[0].config).not.toHaveProperty('color'); + }); + + it('rewrites legacy chart-N to hue tokens before PATCH in useUpdateDashboard', async () => { + const update = captureMutation(useUpdateDashboard); + + await update({ + id: 'a', + tiles: [ + { id: 't1', x: 0, y: 0, w: 4, h: 4, config: { color: 'chart-10' } }, + ], + }); + + expect(hdxServerMock).toHaveBeenCalledWith( + 'dashboards/a', + expect.objectContaining({ + method: 'PATCH', + json: expect.objectContaining({ + tiles: [expect.objectContaining({ config: { color: 'chart-gray' } })], + }), + }), + ); + }); + + it('passes hue tokens through unchanged in useUpdateDashboard', async () => { + const update = captureMutation(useUpdateDashboard); + + await update({ + id: 'a', + tiles: [ + { + id: 't1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { color: 'chart-orange' }, + }, + ], + }); + + const call = hdxServerMock.mock.calls[0]; + expect(call[1].json.tiles[0].config).toEqual({ color: 'chart-orange' }); + }); +}); + +// Pre-validation walker used by `DBDashboardImportPage`. Operates on +// `unknown` so JSON-imported templates can be healed *before* the strict +// `DashboardTemplateSchema.safeParse` rejects legacy `chart-N` with an +// opaque enum error. Distinct from `normalizeDashboardTileColors` in +// that unresolvable strings are left in place so the schema can report +// the bad value via its native error path. +describe('normalizeRawDashboardTileColors', () => { + it('rewrites legacy chart-N inside a tile config', () => { + const input = { + name: 'D', + tiles: [{ config: { color: 'chart-2' } }], + }; + expect(normalizeRawDashboardTileColors(input)).toEqual({ + name: 'D', + tiles: [{ config: { color: 'chart-blue' } }], + }); + }); + + it('leaves unresolvable color strings in place for the schema to flag', () => { + const input = { tiles: [{ config: { color: 'chart-future-magenta' } }] }; + const result = normalizeRawDashboardTileColors(input) as { + tiles: Array<{ config: { color: string } }>; + }; + expect(result.tiles[0].config.color).toBe('chart-future-magenta'); + }); + + it('returns the input untouched when tiles is missing or non-array', () => { + expect(normalizeRawDashboardTileColors({ name: 'D' })).toEqual({ + name: 'D', + }); + expect(normalizeRawDashboardTileColors({ tiles: 'oops' })).toEqual({ + tiles: 'oops', + }); + expect(normalizeRawDashboardTileColors(null)).toBeNull(); + expect(normalizeRawDashboardTileColors('not-an-object')).toBe( + 'not-an-object', + ); + }); + + it('preserves referential equality when nothing changes', () => { + const input = { tiles: [{ config: { color: 'chart-orange' } }] }; + expect(normalizeRawDashboardTileColors(input)).toBe(input); + }); + + it('skips tiles whose config has no color', () => { + const input = { tiles: [{ config: { displayType: 'line' } }] }; + expect(normalizeRawDashboardTileColors(input)).toBe(input); + }); +}); diff --git a/packages/app/src/__tests__/dashboard.test.ts b/packages/app/src/__tests__/dashboard.test.ts index 80ffb6c5c4..297098bee6 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -64,20 +64,26 @@ describe('fetchLocalDashboards', () => { ); }; - it('migrates chart-1..10 to their HyperDX-slot-order hue equivalents', () => { - storeDashboardWithTileColor('chart-1'); + // Locked-in slot-to-hue mapping. Parameterized across all 10 slots + // so a future tweak to `LEGACY_CHART_PALETTE_TOKEN_MAP` is caught + // here instead of by a stored dashboard silently recoloring on the + // user's next reload. Order matches the HyperDX `_tokens.scss` + // categorical palette. + it.each([ + ['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'], + ])('migrates legacy %s → %s', (legacy, hue) => { + storeDashboardWithTileColor(legacy); expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ - color: 'chart-green', - }); - - storeDashboardWithTileColor('chart-2'); - expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ - color: 'chart-blue', - }); - - storeDashboardWithTileColor('chart-10'); - expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ - color: 'chart-gray', + color: hue, }); }); @@ -88,15 +94,17 @@ describe('fetchLocalDashboards', () => { }); }); - it('leaves unknown strings alone (no silent data loss)', () => { - // A forward-compat or hand-edited value should survive the - // normalizer untouched; render-time consumers can decide to - // ignore it. The alternative — erasing the value — would be - // worse than leaving it. + it('strips unresolvable color strings so they never reach the strict schema', () => { + // A stale hex / hand-edited / future-rollback value would 400 the + // server-side `ChartPaletteTokenSchema` on next save if it sailed + // through unchanged. The normalizer drops the field instead, so + // the tile renders as the default series color and the next PATCH + // doesn't blow up. storeDashboardWithTileColor('chart-future-magenta'); - expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ - color: 'chart-future-magenta', - }); + const config = fetchLocalDashboards()[0].tiles[0].config as { + color?: unknown; + }; + expect(config).not.toHaveProperty('color'); }); it('does not touch tiles whose config has no color field', () => { diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index f67574b18d..78f3aa8413 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -21,15 +21,10 @@ import type { WebhookTestApiResponse, WebhookUpdateApiResponse, } from '@hyperdx/common-utils/dist/types'; -import type { UseQueryOptions } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query'; import { IS_LOCAL_MODE } from './config'; -import { - Dashboard, - fetchLocalDashboards, - getLocalDashboardTags, -} from './dashboard'; +import { getLocalDashboardTags } from './dashboard'; type ServicesResponse = { data: Record< string, @@ -127,64 +122,6 @@ const api = { }, }); }, - useDashboards(options?: UseQueryOptions) { - return useQuery({ - queryKey: [`dashboards`], - queryFn: IS_LOCAL_MODE - ? async () => fetchLocalDashboards() - : () => hdxServer(`dashboards`, { method: 'GET' }).json(), - ...options, - }); - }, - useCreateDashboard() { - return useMutation({ - mutationFn: async ({ - name, - charts, - query, - tags, - }: { - name: string; - charts: Dashboard['tiles']; - query: string; - tags: string[]; - }) => - hdxServer(`dashboards`, { - method: 'POST', - json: { name, charts, query, tags }, - }).json(), - }); - }, - useUpdateDashboard() { - return useMutation({ - mutationFn: async ({ - id, - name, - charts, - query, - tags, - }: { - id: string; - name: string; - charts: Dashboard['tiles']; - query: string; - tags: string[]; - }) => - hdxServer(`dashboards/${id}`, { - method: 'PUT', - json: { name, charts, query, tags }, - }).json(), - }); - }, - useDeleteDashboard() { - return useMutation({ - mutationFn: async ({ id }: { id: string }) => { - await hdxServer(`dashboards/${id}`, { - method: 'DELETE', - }); - }, - }); - }, usePresetDashboardFilters( presetDashboard: PresetDashboard, sourceId: string, diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index ee92d0dc73..4ebd0bbe9f 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -58,12 +58,15 @@ const localDashboards = createEntityStore('hdx-local-dashboards'); * return a Zod 400. Symmetric application also lets the DB-side data * converge on next save instead of holding legacy tokens forever. * - * Unknown strings are left untouched so we don't accidentally erase - * forward-compat values; hue tokens already match the resolver and - * pass through unchanged. The returned tile array preserves identity - * where nothing changed so React reconciliation stays cheap and the - * helper is safe to call on payloads that omit `tiles` entirely - * (partial PATCHes). + * Hue tokens already match the resolver and pass through unchanged. + * Strings that aren't resolvable to a current `ChartPaletteToken` + * (stale hexes, hand-edited values, future tokens from a forward-rolled + * deploy) are stripped from `config.color` rather than passed through — + * leaving them in place would trip the strict server-side + * `ChartPaletteTokenSchema` on the next save and 400 the request. The + * returned tile array preserves identity where nothing changed so React + * reconciliation stays cheap, and the helper is safe to call on payloads + * that omit `tiles` entirely (partial PATCHes). */ function normalizeDashboardTileColors( dashboard: T, @@ -71,12 +74,15 @@ function normalizeDashboardTileColors( if (!dashboard.tiles || dashboard.tiles.length === 0) return dashboard; let changed = false; const tiles = dashboard.tiles.map(tile => { - const config = tile.config as { color?: unknown }; - const current = config?.color; + const current = tile.config?.color; if (typeof current !== 'string') return tile; const resolved = resolveChartPaletteToken(current); - if (resolved === undefined || resolved === current) return tile; + if (resolved === current) return tile; changed = true; + if (resolved === undefined) { + const { color: _drop, ...rest } = tile.config; + return { ...tile, config: rest as Tile['config'] }; + } return { ...tile, config: { ...tile.config, color: resolved } as Tile['config'], @@ -85,7 +91,50 @@ function normalizeDashboardTileColors( return changed ? { ...dashboard, tiles } : dashboard; } -// Exported for testing; the React layer should reach for `useDashboards`. +/** + * Walks a parsed-but-not-yet-validated JSON payload and rewrites every + * `tiles[i].config.color` that points at a legacy `chart-1`..`chart-10` + * value to its hue-named equivalent. Mirrors `normalizeDashboardTileColors` + * but operates on `unknown` data so the JSON-import flow in + * `DBDashboardImportPage` can heal legacy values *before* the strict + * `DashboardTemplateSchema` parse (which would reject the legacy enum and + * trip an error toast). + * + * Unlike `normalizeDashboardTileColors`, this raw variant leaves unknown + * non-token strings in place so the schema's own validation message + * surfaces them to the user with the proper "Invalid enum value" copy — + * stripping the field here would silently swallow a typo in an + * imported file. + */ +export function normalizeRawDashboardTileColors(input: unknown): 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.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 resolved = resolveChartPaletteToken(current); + if (resolved === undefined || resolved === current) return tile; + changed = true; + return { ...t, config: { ...c, color: resolved } }; + }); + return changed ? { ...root, tiles: nextTiles } : input; +} + +/** + * Shared queryFn behind `useDashboards`. Exported so tests can call it + * directly (notably `dashboard.remote.test.ts`, which exercises the + * non-local branch in isolation). React components should keep going + * through `useDashboards` so React Query caching and invalidation + * stays uniform. + */ export async function fetchDashboards(): Promise { if (IS_LOCAL_MODE) { return localDashboards.getAll().map(normalizeDashboardTileColors); @@ -186,7 +235,12 @@ export function useDashboard({ const dashboard: Dashboard | undefined = useMemo(() => { if (isLocalDashboard) { - return localDashboard ?? defaultDashboard; + // URL-state local dashboards bypass `fetchDashboards`, so heal + // any legacy `chart-N` here too (symmetric with the write-time + // pass in `setDashboard` below). + return localDashboard + ? normalizeDashboardTileColors(localDashboard) + : defaultDashboard; } return remoteDashboard; }, [isLocalDashboard, localDashboard, defaultDashboard, remoteDashboard]); @@ -198,7 +252,11 @@ export function useDashboard({ onError?: VoidFunction, ) => { if (isLocalDashboard) { - setLocalDashboard(newDashboard); + // Normalize on write too so the URL-state local dashboard never + // holds a legacy `chart-N` after a no-fetch path (e.g. a tile + // inserted via a preset literal) and matches the canonical hue + // tokens used by the renderers. + setLocalDashboard(normalizeDashboardTileColors(newDashboard)); onSuccess?.(); } else { setIsSettingDashboard(true); diff --git a/packages/app/src/theme/semanticColorsGrouped.ts b/packages/app/src/theme/semanticColorsGrouped.ts index 6e20011b10..5c21f10527 100644 --- a/packages/app/src/theme/semanticColorsGrouped.ts +++ b/packages/app/src/theme/semanticColorsGrouped.ts @@ -70,6 +70,8 @@ export const semanticColorsGrouped = { '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 index 0f937f9394..4c9454babf 100644 --- a/packages/app/src/theme/themes/_chart-categorical-tokens.scss +++ b/packages/app/src/theme/themes/_chart-categorical-tokens.scss @@ -41,14 +41,18 @@ } /* - * Semantic chart colors — unified on both brands. `success` and `info` alias - * categorical `chart-green` and `chart-blue`; warning and error use shared - * Observable hexes. Must be `@include`d after `chart-categorical-tokens`. + * 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: #efb118; - --color-chart-error: #ff725c; + --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) */ diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 6527c02cdf..adaa776dee 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -379,6 +379,8 @@ export const getLogLevelClass = (lvl: string | undefined) => { * (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', @@ -390,15 +392,53 @@ const CATEGORICAL_HEX_BY_TOKEN = { 'chart-light-blue': '#97bbf5', 'chart-brown': '#9c6b4e', 'chart-gray': '#9498a0', -} as const satisfies Record; +} 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). + * + * The two brand entries are intentionally byte-identical today — the + * palette is unified across HyperDX and ClickStack post-#2362. The + * per-brand shape (and the explicit `Record<…, SemanticChartHexes>` + * constraint) is preserved as a future override hook AND so the type + * system enforces parity: dropping or renaming a key in one brand + * without mirroring it in the other becomes a compile error instead + * of a silent runtime divergence between SSR and client. */ -const SEMANTIC_CHART_PALETTE = { +type SemanticChartHexes = Readonly>; + +const SEMANTIC_CHART_PALETTE: Readonly< + Record<'hyperdx' | 'clickstack', SemanticChartHexes> +> = { hyperdx: { success: '#3ca951', warning: '#efb118', @@ -417,9 +457,7 @@ const SEMANTIC_CHART_PALETTE = { warningHighlight: '#f5c94d', errorHighlight: '#ffa090', }, -} as const; - -type SemanticChartColorKey = keyof typeof SEMANTIC_CHART_PALETTE.hyperdx; +}; /** * Ordered hex array for positional series assignment. @@ -427,11 +465,15 @@ type SemanticChartColorKey = keyof typeof SEMANTIC_CHART_PALETTE.hyperdx; * 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. */ -export const COLORS: string[] = CATEGORICAL_PALETTE_TOKENS.map( - token => - CATEGORICAL_HEX_BY_TOKEN[token as keyof typeof CATEGORICAL_HEX_BY_TOKEN], -); +export const COLORS: readonly string[] = ( + CATEGORICAL_PALETTE_TOKENS as readonly CategoricalChartPaletteToken[] +).map(token => CATEGORICAL_HEX_BY_TOKEN[token]); /** * Palette token types and runtime guards live in common-utils so the @@ -510,10 +552,15 @@ export function getColorFromCSSToken(token: ChartPaletteToken): string { return CATEGORICAL_HEX_BY_TOKEN[token]; } - const cssVarName = `--color-${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 semanticTokenFallback(token); + return semanticTokenFallback(semanticToken); } try { @@ -526,7 +573,7 @@ export function getColorFromCSSToken(token: ChartPaletteToken): string { // Fallback if getComputedStyle fails } - return semanticTokenFallback(token); + return semanticTokenFallback(semanticToken); } function isCategoricalChartPaletteToken( @@ -535,7 +582,9 @@ function isCategoricalChartPaletteToken( return Object.prototype.hasOwnProperty.call(CATEGORICAL_HEX_BY_TOKEN, token); } -function semanticTokenFallback(token: ChartPaletteToken): string { +function semanticTokenFallback( + token: Exclude, +): string { switch (token) { case 'chart-success': case 'chart-warning': @@ -547,9 +596,16 @@ function semanticTokenFallback(token: ChartPaletteToken): string { | 'error'; return SEMANTIC_CHART_PALETTE[theme][key]; } - default: - // Unknown token; fall back to brand-primary so we render *something*. - return COLORS[0]; + default: { + // 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}`); + } } } diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index b2787dfe4d..67dd7ee953 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -796,7 +796,12 @@ export function isOnClickDashboardById( * Tokens map to CSS variables in * `packages/app/src/theme/themes//_tokens.scss`: * chart-{hue} -> --color-chart-{hue} (10 hues, unified across themes) - * chart-success/warning/error/info -> --color-chart-{success|warning|error|info} (semantic; unified across brands) + * 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,7 +811,12 @@ export function isOnClickDashboardById( * stays in `packages/app/src/utils.ts` because it depends on * `getComputedStyle(document.documentElement)`. */ -export const CHART_PALETTE_TOKENS = [ +/** 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', @@ -817,23 +827,35 @@ export const CHART_PALETTE_TOKENS = [ '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 type ChartPaletteToken = (typeof CHART_PALETTE_TOKENS)[number]; +export const CHART_PALETTE_TOKENS = [ + ...CATEGORICAL_PALETTE_TOKENS, + ...SEMANTIC_PALETTE_TOKENS, +] as const; -/** Categorical tokens (10 hues). */ -export const CATEGORICAL_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice( - 0, - 10, -) as readonly ChartPaletteToken[]; +export type ChartPaletteToken = (typeof CHART_PALETTE_TOKENS)[number]; -/** Semantic tokens (success / warning / error). */ -export const SEMANTIC_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice( - 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'; /** * Legacy numeric tokens (`chart-1` .. `chart-10`) shipped in the initial @@ -844,6 +866,16 @@ export const SEMANTIC_PALETTE_TOKENS = CHART_PALETTE_TOKENS.slice( * 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', @@ -856,7 +888,7 @@ export const LEGACY_CHART_PALETTE_TOKEN_MAP = { 'chart-8': 'chart-light-blue', 'chart-9': 'chart-brown', 'chart-10': 'chart-gray', -} as const satisfies Record; +} as const satisfies Record; export type LegacyChartPaletteToken = keyof typeof LEGACY_CHART_PALETTE_TOKEN_MAP; From 9b756d573d706769ef319a632844291fb8e10d01 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 14:42:03 +0100 Subject: [PATCH 15/17] fix(theme): address round-2 deep-review findings on chart palette rename P2: - Migrate legacy chart-N in GET dashboards controllers so the wire format emits canonical hue-named tokens. Pre-rename Mongo docs can no longer round-trip via non-React HTTP callers (GET -> unmodified PATCH) and 400 the strict ChartPaletteTokenSchema. - Add integration test that seeds a chart-1 dashboard directly into Mongo, then asserts both list and single GET return chart-green. - Revert silent strip of unresolvable colors in normalizeDashboardTileColors. Stale hexes / hand-edited values / forward-rolled future tokens now pass through so the strict server-side schema surfaces a clear error on next save instead of the normalizer quietly dropping the user's chosen color. Matches the import-time policy of normalizeRawDashboardTileColors. - Add provisioner test pinning the inlined walker + non-array tiles early-return. P3: - Extract walkRawDashboardTileColors into common-utils. Four call sites (React fetch/write normalizer, JSON import pre-validation, API write shim, provisioner) now compose over a single per-tile traversal, with the migration policy supplied as a callback. Adds dedicated unit tests pinning the walker contract. - Drop the no-op `as readonly CategoricalChartPaletteToken[]` cast on the COLORS construction. CATEGORICAL_PALETTE_TOKENS is already a readonly tuple of the narrow union so the .map callback is exhaustive without further assertion. - Trim SEMANTIC_CHART_PALETTE comment to the parity-enforcement justification only (drop the aspirational "future override hook" framing). - Add a one-line comment on DBRowTable's PatternTrendChart explaining why the per-row getChartColorInfo() call is intentionally not memoized (memoization would require a theme-class subscription this component doesn't already have, and the per-row getComputedStyle cost is sub-microsecond). Co-authored-by: Cursor --- .changeset/rename-chart-palette-tokens.md | 20 ++++- packages/api/src/controllers/dashboard.ts | 35 +++++++- .../routers/api/__tests__/dashboard.test.ts | 26 ++++++ packages/api/src/routers/api/dashboards.ts | 24 ++--- .../__tests__/provisionDashboards.test.ts | 31 +++++++ .../src/tasks/provisionDashboards/index.ts | 40 ++++----- .../src/__tests__/dashboard.remote.test.ts | 13 +-- packages/app/src/__tests__/dashboard.test.ts | 19 ++-- packages/app/src/components/DBRowTable.tsx | 10 +++ packages/app/src/dashboard.ts | 89 +++++++------------ packages/app/src/utils.ts | 25 +++--- .../common-utils/src/__tests__/guards.test.ts | 61 +++++++++++++ packages/common-utils/src/types.ts | 56 ++++++++++++ 13 files changed, 321 insertions(+), 128 deletions(-) diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md index 83c7355b60..cea4e38ac2 100644 --- a/.changeset/rename-chart-palette-tokens.md +++ b/.changeset/rename-chart-palette-tokens.md @@ -13,19 +13,31 @@ Stored configs from the initial color picker (#2265) keep working. matches `z.output` — wrapping it in `z.preprocess` would poison `validateRequest`'s `req.body` inference all the way up to `Dashboard.tiles[i].config.color`). Migration of legacy `chart-1` .. `chart-10` -happens at four complementary points so no entry path can slip through: +happens at five complementary points so no entry or wire-format path can slip +through, all composing over a single shared walker +(`walkRawDashboardTileColors` in `common-utils`) so the per-tile traversal +stays in lockstep: - **Fetch-time / write-time (React)**: `normalizeDashboardTileColors` in `packages/app/src/dashboard.ts` heals dashboards on read (`useDashboards` / `fetchLocalDashboards` / `fetchDashboards`) and on write (`useUpdateDashboard` / `useCreateDashboard`). Unresolvable color strings - (stale hexes, hand-edited values) are stripped at the boundary so they - never reach the strict server-side schema and 400 the next save. + (stale hexes, hand-edited values, forward-rolled future tokens) are + preserved so the user's chosen value survives a render pass — the strict + server-side schema surfaces a clear error on next save instead of the + normalizer quietly dropping the field. - **JSON import**: `DBDashboardImportPage` runs `normalizeRawDashboardTileColors` on the parsed JSON *before* the strict `DashboardTemplateSchema.safeParse`, so templates exported from a pre-rename deploy import cleanly. -- **Server-side migration shim**: the dashboards POST / PATCH routes mount +- **Server-side GET response healing**: `getDashboards` / `getDashboard` in + `packages/api/src/controllers/dashboard.ts` rewrite legacy tile colors on + the way out. Pre-rename Mongo docs are served on the wire as + hue-named tokens so non-React HTTP clients (CI scripts, stale bundle + tabs during a rolling deploy, the external API) can round-trip + GET → PATCH without ever resurrecting `chart-N` through the strict + schema. +- **Server-side write shim**: the dashboards POST / PATCH routes mount a request-body preprocessor that rewrites legacy tile colors before `validateRequest` runs `ChartPaletteTokenSchema`. Catches non-React HTTP callers (stale-bundle tabs during a rolling deploy, CI scripts, diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index 92a93e4c17..01ab5c6e77 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -1,7 +1,9 @@ import { DashboardWithoutIdSchema, + resolveChartPaletteToken, SavedChartConfig, Tile, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import { map, partition, uniq } from 'lodash'; import { z } from 'zod'; @@ -25,6 +27,32 @@ function pickAlertsByTile(tiles: Tile[]) { }, {}); } +/** + * Rewrite any legacy `chart-1`..`chart-10` tile colors from #2265 in + * an already-serialized dashboard JSON to their hue-named equivalents + * before it leaves the server. Keeps the wire format on a single + * canonical vocabulary so non-React HTTP clients (CI scripts, stale + * bundle tabs during a rolling deploy, the upcoming external API + * surface) never have to know about the legacy values, and so a + * GET → unmodified PATCH round-trip on a Mongo-seeded legacy doc can + * never resurrect the legacy tokens through the strict server-side + * `ChartPaletteTokenSchema`. The React-side + * `normalizeDashboardTileColors` becomes redundant for the wire path + * after this lands but stays in place as defense in depth for + * `IS_LOCAL_MODE` and in-memory tile literals. + * + * Unresolvable strings (stale hexes, hand-edited values, forward-rolled + * future tokens) pass through untouched so the user's data is not + * silently dropped; the strict schema surfaces a clear error on next + * save. + */ +function healLegacyDashboardTileColors(dashboard: T): T { + return walkRawDashboardTileColors(dashboard, current => { + const resolved = resolveChartPaletteToken(current); + return resolved ?? current; + }) as T; +} + type TileForAlertSync = Pick & { config?: Pick | { alert?: IAlert | AlertDocument }; }; @@ -107,7 +135,8 @@ export async function getDashboards(teamId: ObjectId) { alert: alerts[`${d._id.toString()}:${t.id}`]?.[0], }, })), - })); + })) + .map(healLegacyDashboardTileColors); return dashboards; } @@ -120,13 +149,13 @@ export async function getDashboard(dashboardId: string, teamId: ObjectId) { getDashboardAlertsByTile(teamId, dashboardId), ]); - return { + return healLegacyDashboardTileColors({ ..._dashboard?.toJSON(), tiles: _dashboard?.tiles.map(t => ({ ...t, config: { ...t.config, alert: alerts[t.id]?.[0] }, })), - }; + }); } export async function createDashboard( diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 97c9a7d6a5..e53d80ca28 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -124,6 +124,32 @@ describe('dashboard router', () => { expect(updated.body.tiles[0].config.color).toBe('chart-gray'); }); + // Wire-format guarantee: GET emits canonical hue-named tokens even for + // dashboards that pre-date the rename and still hold `chart-1`..`chart-10` + // in Mongo. Without this, a non-React client (or a stale React bundle + // that bypasses `normalizeDashboardTileColors`) could GET a legacy + // color and round-trip it back through PATCH where the strict schema + // would 400. Bypasses the API to seed the legacy value directly into + // Mongo because the POST/PATCH middleware would otherwise rewrite it + // before it ever reached the DB. + it('returns hue-named tokens on GET for a Mongo-seeded legacy chart-N tile', async () => { + const tileWithLegacy = makeTile(); + (tileWithLegacy.config as any).color = 'chart-1'; + const seeded = await Dashboard.create({ + name: 'Pre-rename Dashboard', + team: team._id, + tiles: [tileWithLegacy], + tags: [], + }); + + const list = await agent.get('/dashboards').expect(200); + const fromList = list.body.find(d => d._id === seeded._id.toString()); + expect(fromList.tiles[0].config.color).toBe('chart-green'); + + const single = await agent.get(`/dashboards/${seeded._id}`).expect(200); + expect(single.body.tiles[0].config.color).toBe('chart-green'); + }); + it('sets createdBy and updatedBy on create and populates them in GET', async () => { const created = await agent .post('/dashboards') diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index f53cb1b685..71112b756a 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -4,6 +4,7 @@ import { PresetDashboard, PresetDashboardFilterSchema, resolveChartPaletteToken, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; @@ -40,28 +41,19 @@ const router = express.Router(); * * This is a one-release deprecation shim — once stored data has converged * on the hue-named tokens, it can be removed in favor of straight-strict - * validation. + * validation. The actual walk delegates to `walkRawDashboardTileColors` + * in common-utils so this middleware, the app-side normalizer, the JSON + * import path, and the provisioner all share the same per-tile traversal. */ const migrateLegacyDashboardTileColors: express.RequestHandler = ( req, _res, next, ) => { - const body: unknown = req.body; - if (!body || typeof body !== 'object') return next(); - const tiles = (body as { tiles?: unknown }).tiles; - if (!Array.isArray(tiles)) return next(); - for (const tile of tiles) { - if (!tile || typeof tile !== 'object') continue; - const config = (tile as { config?: unknown }).config; - if (!config || typeof config !== 'object') continue; - const c = config as { color?: unknown }; - if (typeof c.color !== 'string') continue; - const resolved = resolveChartPaletteToken(c.color); - if (resolved !== undefined && resolved !== c.color) { - c.color = resolved; - } - } + req.body = walkRawDashboardTileColors(req.body, current => { + const resolved = resolveChartPaletteToken(current); + return resolved ?? current; + }); next(); }; diff --git a/packages/api/src/tasks/provisionDashboards/__tests__/provisionDashboards.test.ts b/packages/api/src/tasks/provisionDashboards/__tests__/provisionDashboards.test.ts index 69ad2d3fd0..5991156d6f 100644 --- a/packages/api/src/tasks/provisionDashboards/__tests__/provisionDashboards.test.ts +++ b/packages/api/src/tasks/provisionDashboards/__tests__/provisionDashboards.test.ts @@ -81,6 +81,37 @@ describe('provisionDashboards', () => { expect(result).toHaveLength(1); expect(result[0].name).toBe('Test'); }); + + // Pins the inlined `migrateLegacyDashboardTileColorsRaw` walker + // (which delegates to `walkRawDashboardTileColors` in common-utils). + // Without the migration the strict `DashboardWithoutIdSchema` + // rejects the legacy enum and `readDashboardFiles` skips the file + // outright, so a provisioned dashboard authored against #2265 + // wouldn't ship at all. + it('migrates legacy chart-N tile colors before schema validation', () => { + const tile = makeTile(); + (tile.config as any).color = 'chart-1'; + fs.writeFileSync( + path.join(tmpDir, 'legacy.json'), + JSON.stringify({ name: 'Legacy', tiles: [tile], tags: [] }), + ); + const result = readDashboardFiles(tmpDir); + expect(result).toHaveLength(1); + expect((result[0].tiles[0].config as any).color).toBe('chart-green'); + }); + + // Early-return for files whose `tiles` is non-array (or missing). + // The walker leaves the payload untouched, so the file behaves + // exactly like any other schema-invalid input: skipped, not + // crashed. + it('does not crash on a file whose tiles is not an array', () => { + fs.writeFileSync( + path.join(tmpDir, 'tiles-string.json'), + JSON.stringify({ name: 'Bad Tiles', tiles: 'not-an-array', tags: [] }), + ); + expect(() => readDashboardFiles(tmpDir)).not.toThrow(); + expect(readDashboardFiles(tmpDir)).toEqual([]); + }); }); describe('syncDashboards', () => { diff --git a/packages/api/src/tasks/provisionDashboards/index.ts b/packages/api/src/tasks/provisionDashboards/index.ts index e53427c2d1..79ac66ca7e 100644 --- a/packages/api/src/tasks/provisionDashboards/index.ts +++ b/packages/api/src/tasks/provisionDashboards/index.ts @@ -2,6 +2,7 @@ import { DashboardWithoutId, DashboardWithoutIdSchema, resolveChartPaletteToken, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import fs from 'fs'; import path from 'path'; @@ -13,26 +14,18 @@ import type { HdxTask } from '@/tasks/types'; import { ProvisionDashboardsTaskArgs } from '@/tasks/types'; import logger from '@/utils/logger'; -// Walks parsed-but-unvalidated JSON and rewrites any legacy -// `chart-1`..`chart-10` tile colors from #2265 to their hue-named -// equivalents. Mirrors `normalizeRawDashboardTileColors` in the app -// package; the provisioner can't import from `packages/app`, so the -// minimal walker is inlined here. -function migrateLegacyDashboardTileColorsInPlace(raw: unknown): void { - if (!raw || typeof raw !== 'object') return; - const tiles = (raw as { tiles?: unknown }).tiles; - if (!Array.isArray(tiles)) return; - for (const tile of tiles) { - if (!tile || typeof tile !== 'object') continue; - const config = (tile as { config?: unknown }).config; - if (!config || typeof config !== 'object') continue; - const c = config as { color?: unknown }; - if (typeof c.color !== 'string') continue; - const resolved = resolveChartPaletteToken(c.color); - if (resolved !== undefined && resolved !== c.color) { - c.color = resolved; - } - } +// Heal legacy `chart-1`..`chart-10` tile colors from #2265 before the +// strict `DashboardWithoutIdSchema` parse rejects them. Same policy as +// the React `normalizeDashboardTileColors` and the API router's +// `migrateLegacyDashboardTileColors`: hue tokens pass through, legacy +// numeric tokens are rewritten to hue-named equivalents, and unknown +// strings are left intact so the schema's native enum error surfaces +// in the warn log instead of silently dropping the field. +function migrateLegacyDashboardTileColorsRaw(raw: unknown): unknown { + return walkRawDashboardTileColors(raw, current => { + const resolved = resolveChartPaletteToken(current); + return resolved ?? current; + }); } export function readDashboardFiles(dir: string): DashboardWithoutId[] { @@ -47,11 +40,12 @@ export function readDashboardFiles(dir: string): DashboardWithoutId[] { const dashboards: DashboardWithoutId[] = []; for (const file of files) { try { - const raw = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); - migrateLegacyDashboardTileColorsInPlace(raw); + const raw = migrateLegacyDashboardTileColorsRaw( + JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')), + ) as Record | null | undefined; const parsed = DashboardWithoutIdSchema.safeParse({ tags: [], - ...raw, + ...(raw as object), }); if (!parsed.success) { logger.warn( diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts index cd2f17453a..d5850df84e 100644 --- a/packages/app/src/__tests__/dashboard.remote.test.ts +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -95,12 +95,14 @@ describe('fetchDashboards (remote path)', () => { expect(result[0].tiles[0].config).toMatchObject({ color: 'chart-orange' }); }); - it('strips unresolvable color strings so they never reach the strict schema', async () => { + it('leaves unresolvable color strings intact (no silent data loss)', async () => { setRemotePayload(remoteDashboardWithTileColor('chart-future-magenta')); const result = await fetchDashboards(); - expect(result[0].tiles[0].config).not.toHaveProperty('color'); + expect(result[0].tiles[0].config).toMatchObject({ + color: 'chart-future-magenta', + }); }); it('does not touch tiles whose config has no color field', async () => { @@ -169,7 +171,7 @@ describe('useCreateDashboard / useUpdateDashboard write-time normalization', () ); }); - it('strips unresolvable color before POST in useCreateDashboard', async () => { + it('preserves unresolvable color through POST so the server can surface a clear schema error', async () => { const create = captureMutation(useCreateDashboard); await create({ @@ -188,8 +190,9 @@ describe('useCreateDashboard / useUpdateDashboard write-time normalization', () }); const call = hdxServerMock.mock.calls[0]; - const body = call[1].json; - expect(body.tiles[0].config).not.toHaveProperty('color'); + expect(call[1].json.tiles[0].config).toMatchObject({ + color: 'chart-future-magenta', + }); }); it('rewrites legacy chart-N to hue tokens before PATCH in useUpdateDashboard', async () => { diff --git a/packages/app/src/__tests__/dashboard.test.ts b/packages/app/src/__tests__/dashboard.test.ts index 297098bee6..215c848345 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -94,17 +94,16 @@ describe('fetchLocalDashboards', () => { }); }); - it('strips unresolvable color strings so they never reach the strict schema', () => { - // A stale hex / hand-edited / future-rollback value would 400 the - // server-side `ChartPaletteTokenSchema` on next save if it sailed - // through unchanged. The normalizer drops the field instead, so - // the tile renders as the default series color and the next PATCH - // doesn't blow up. + it('leaves unresolvable color strings intact (no silent data loss)', () => { + // A stale hex / hand-edited / future-rollback value is preserved + // so the user's choice survives a render pass — the strict + // server-side `ChartPaletteTokenSchema` surfaces a clear error on + // next save instead of the normalizer quietly dropping the field + // and irreversibly overwriting the user's pick. storeDashboardWithTileColor('chart-future-magenta'); - const config = fetchLocalDashboards()[0].tiles[0].config as { - color?: unknown; - }; - expect(config).not.toHaveProperty('color'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-future-magenta', + }); }); it('does not touch tiles whose config has no color field', () => { diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index a125e225b8..683cf0f4ac 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -269,6 +269,16 @@ const PatternTrendChart = ({ isAnimationActive={false} dataKey="count" stackId="a" + // `getChartColorInfo()` resolves a CSS var via + // `getComputedStyle(document.documentElement)` and is + // invoked once per row render. Kept inline (instead of + // hoisted into a memo) because memoizing would either + // require a stable theme-class subscription this + // component doesn't already have, or risk a stale + // value on theme toggle. The per-row cost is acceptable: + // pattern rows render in a virtualized list and the + // `getComputedStyle` read is sub-microsecond. Revisit + // if this surfaces in a profile. fill={color || getChartColorInfo()} maxBarSize={24} /> diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 4ebd0bbe9f..8e52d3813a 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -7,6 +7,7 @@ import { resolveChartPaletteToken, SavedChartConfig, SearchConditionLanguage, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import { notifications } from '@mantine/notifications'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -47,6 +48,23 @@ export type Dashboard = { const localDashboards = createEntityStore('hdx-local-dashboards'); +/** + * Resolution policy shared by both the typed normalizer below and the + * `unknown`-walking JSON-import variant: hue tokens pass through + * unchanged, legacy `chart-N` from #2265 are rewritten to their + * hue-named equivalents, and anything else (stale hexes, hand-edited + * values, future tokens from a forward-rolled deploy) is left as-is so + * the strict server-side `ChartPaletteTokenSchema` surfaces a clear + * error on save rather than silently dropping the user's chosen color. + * Render-time consumers (`DBNumberChart`, `ColorSwatchInput`) still + * call `resolveChartPaletteToken` directly so the live chart falls + * back gracefully even while the unresolved value is in flight. + */ +const migrateOrPreserveColor = (current: string): string => { + const resolved = resolveChartPaletteToken(current); + return resolved ?? current; +}; + /** * Heal legacy `chart-1`..`chart-10` colors stored on tiles by #2265 * into their hue-named equivalents. Applied at fetch time so every @@ -58,74 +76,33 @@ const localDashboards = createEntityStore('hdx-local-dashboards'); * return a Zod 400. Symmetric application also lets the DB-side data * converge on next save instead of holding legacy tokens forever. * - * Hue tokens already match the resolver and pass through unchanged. - * Strings that aren't resolvable to a current `ChartPaletteToken` - * (stale hexes, hand-edited values, future tokens from a forward-rolled - * deploy) are stripped from `config.color` rather than passed through — - * leaving them in place would trip the strict server-side - * `ChartPaletteTokenSchema` on the next save and 400 the request. The - * returned tile array preserves identity where nothing changed so React - * reconciliation stays cheap, and the helper is safe to call on payloads - * that omit `tiles` entirely (partial PATCHes). + * Delegates the per-tile walk to `walkRawDashboardTileColors` in + * common-utils so the unknown-import path and the API-side migration + * shim stay in lockstep with this typed variant. The double cast is + * unavoidable: the shared walker is generic over `unknown` so the + * provisioner / API can call it, and TypeScript can't carry the + * `tiles[]` element type through. The return shape is structurally + * identical to the input. */ function normalizeDashboardTileColors( dashboard: T, ): T { if (!dashboard.tiles || dashboard.tiles.length === 0) return dashboard; - let changed = false; - const tiles = dashboard.tiles.map(tile => { - const current = tile.config?.color; - if (typeof current !== 'string') return tile; - const resolved = resolveChartPaletteToken(current); - if (resolved === current) return tile; - changed = true; - if (resolved === undefined) { - const { color: _drop, ...rest } = tile.config; - return { ...tile, config: rest as Tile['config'] }; - } - return { - ...tile, - config: { ...tile.config, color: resolved } as Tile['config'], - }; - }); - return changed ? { ...dashboard, tiles } : dashboard; + return walkRawDashboardTileColors(dashboard, migrateOrPreserveColor) as T; } /** * Walks a parsed-but-not-yet-validated JSON payload and rewrites every * `tiles[i].config.color` that points at a legacy `chart-1`..`chart-10` - * value to its hue-named equivalent. Mirrors `normalizeDashboardTileColors` - * but operates on `unknown` data so the JSON-import flow in - * `DBDashboardImportPage` can heal legacy values *before* the strict - * `DashboardTemplateSchema` parse (which would reject the legacy enum and - * trip an error toast). - * - * Unlike `normalizeDashboardTileColors`, this raw variant leaves unknown - * non-token strings in place so the schema's own validation message - * surfaces them to the user with the proper "Invalid enum value" copy — - * stripping the field here would silently swallow a typo in an - * imported file. + * value to its hue-named equivalent. Same policy as + * `normalizeDashboardTileColors` (legacy → hue, unknown left intact for + * the schema to flag) but exposed as `unknown -> unknown` so the + * JSON-import flow in `DBDashboardImportPage` can heal legacy values + * *before* the strict `DashboardTemplateSchema` parse (which would + * otherwise reject the legacy enum and trip an error toast). */ export function normalizeRawDashboardTileColors(input: unknown): 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.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 resolved = resolveChartPaletteToken(current); - if (resolved === undefined || resolved === current) return tile; - changed = true; - return { ...t, config: { ...c, color: resolved } }; - }); - return changed ? { ...root, tiles: nextTiles } : input; + return walkRawDashboardTileColors(input, migrateOrPreserveColor); } /** diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index adaa776dee..8bbac30acf 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -426,13 +426,13 @@ type SemanticChartColorKey = * Live values come from `--color-chart-{success|warning|error|info}[-highlight]` * in `_chart-categorical-tokens.scss` (`chart-semantic-tokens` mixin). * - * The two brand entries are intentionally byte-identical today — the - * palette is unified across HyperDX and ClickStack post-#2362. The - * per-brand shape (and the explicit `Record<…, SemanticChartHexes>` - * constraint) is preserved as a future override hook AND so the type - * system enforces parity: dropping or renaming a key in one brand - * without mirroring it in the other becomes a compile error instead - * of a silent runtime divergence between SSR and client. + * 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>; @@ -469,11 +469,14 @@ const SEMANTIC_CHART_PALETTE: Readonly< * 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. + * 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 as readonly CategoricalChartPaletteToken[] -).map(token => CATEGORICAL_HEX_BY_TOKEN[token]); +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 diff --git a/packages/common-utils/src/__tests__/guards.test.ts b/packages/common-utils/src/__tests__/guards.test.ts index e083547ada..889a84cae5 100644 --- a/packages/common-utils/src/__tests__/guards.test.ts +++ b/packages/common-utils/src/__tests__/guards.test.ts @@ -8,6 +8,7 @@ import { DisplayType, isChartPaletteToken, resolveChartPaletteToken, + walkRawDashboardTileColors, } from '@/types'; describe('isRawSqlSavedChartConfig', () => { @@ -313,3 +314,63 @@ describe('resolveChartPaletteToken', () => { 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 67dd7ee953..55236f12d6 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -939,6 +939,62 @@ export function resolveChartPaletteToken( 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 b1e50596307a2d833d1063b85a564e783c519a9b Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 15:04:31 +0100 Subject: [PATCH 16/17] fix(api): drop single-GET assertion in legacy-color GET test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboards router only exposes a list GET handler; single-dashboard reads on the React side go through `useDashboards` and filter client-side, so there is no `GET /dashboards/:id` route. The new regression test asserted against both endpoints and the single GET hit the express default 404. Drop the single-GET assertion; the list path still exercises `getDashboards` → `healLegacyDashboardTileColors`, and `getDashboard` is still covered in-process via `updateDashboard`'s PATCH-time read. Co-authored-by: Cursor --- .../api/src/routers/api/__tests__/dashboard.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index e53d80ca28..6cafc53da3 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -132,7 +132,14 @@ describe('dashboard router', () => { // would 400. Bypasses the API to seed the legacy value directly into // Mongo because the POST/PATCH middleware would otherwise rewrite it // before it ever reached the DB. - it('returns hue-named tokens on GET for a Mongo-seeded legacy chart-N tile', async () => { + // + // The dashboards router currently only exposes a list GET (no + // `/:id` single GET handler — single-dashboard reads on the React + // side go through `useDashboards` and filter client-side). The + // controller's `getDashboard` healer is still exercised in the same + // process: `updateDashboard` calls `getDashboard` internally before + // PATCH, so a follow-up no-op PATCH would surface a regression. + it('returns hue-named tokens on GET (list) for a Mongo-seeded legacy chart-N tile', async () => { const tileWithLegacy = makeTile(); (tileWithLegacy.config as any).color = 'chart-1'; const seeded = await Dashboard.create({ @@ -144,10 +151,8 @@ describe('dashboard router', () => { const list = await agent.get('/dashboards').expect(200); const fromList = list.body.find(d => d._id === seeded._id.toString()); + expect(fromList).toBeDefined(); expect(fromList.tiles[0].config.color).toBe('chart-green'); - - const single = await agent.get(`/dashboards/${seeded._id}`).expect(200); - expect(single.body.tiles[0].config.color).toBe('chart-green'); }); it('sets createdBy and updatedBy on create and populates them in GET', async () => { From 2dae3c2ea69a26f51ddb082ff70a5d9f5dc47ed8 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 5 Jun 2026 12:20:59 +0100 Subject: [PATCH 17/17] test(theme): import chart palette tokens from common-utils in tests Address review nit: derive the legacy->hue migration cases and the categorical hue list from the canonical `LEGACY_CHART_PALETTE_TOKEN_MAP` and `CATEGORICAL_PALETTE_TOKENS` exports instead of redefining them inline, so future token changes only need to happen in one place. Co-authored-by: Cursor --- .../src/__tests__/dashboard.remote.test.ts | 30 ++++++++----------- packages/app/src/__tests__/dashboard.test.ts | 25 +++++----------- .../common-utils/src/__tests__/guards.test.ts | 15 ++-------- 3 files changed, 23 insertions(+), 47 deletions(-) diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts index d5850df84e..93c6730cf9 100644 --- a/packages/app/src/__tests__/dashboard.remote.test.ts +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -30,6 +30,8 @@ jest.mock('@tanstack/react-query', () => ({ })); jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) })); +import { LEGACY_CHART_PALETTE_TOKEN_MAP } from '@hyperdx/common-utils/dist/types'; + import { hdxServer } from '../api'; import { fetchDashboards, @@ -40,6 +42,8 @@ import { const hdxServerMock = hdxServer as jest.Mock; +const LEGACY_TO_HUE_CASES = Object.entries(LEGACY_CHART_PALETTE_TOKEN_MAP); + const remoteDashboardWithTileColor = (color: unknown) => [ { id: 'a', @@ -67,25 +71,17 @@ describe('fetchDashboards (remote path)', () => { // downstream consumers see the canonical hue tokens that // `ChartPaletteTokenSchema` accepts. Symmetric coverage with the // local-path suite in `dashboard.test.ts`. - it.each([ - ['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'], - ])('migrates legacy %s → %s from a remote payload', async (legacy, hue) => { - setRemotePayload(remoteDashboardWithTileColor(legacy)); + it.each(LEGACY_TO_HUE_CASES)( + 'migrates legacy %s → %s from a remote payload', + async (legacy, hue) => { + setRemotePayload(remoteDashboardWithTileColor(legacy)); - const result = await fetchDashboards(); + const result = await fetchDashboards(); - expect(hdxServerMock).toHaveBeenCalledWith('dashboards'); - expect(result[0].tiles[0].config).toMatchObject({ color: hue }); - }); + expect(hdxServerMock).toHaveBeenCalledWith('dashboards'); + expect(result[0].tiles[0].config).toMatchObject({ color: hue }); + }, + ); it('passes through hue-named tokens unchanged', async () => { setRemotePayload(remoteDashboardWithTileColor('chart-orange')); diff --git a/packages/app/src/__tests__/dashboard.test.ts b/packages/app/src/__tests__/dashboard.test.ts index 215c848345..2cad91cf30 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -14,10 +14,14 @@ jest.mock('@tanstack/react-query', () => ({ })); jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) })); +import { LEGACY_CHART_PALETTE_TOKEN_MAP } from '@hyperdx/common-utils/dist/types'; + import { fetchLocalDashboards, getLocalDashboardTags } from '../dashboard'; const STORAGE_KEY = 'hdx-local-dashboards'; +const LEGACY_TO_HUE_CASES = Object.entries(LEGACY_CHART_PALETTE_TOKEN_MAP); + beforeEach(() => { localStorage.clear(); }); @@ -64,23 +68,10 @@ describe('fetchLocalDashboards', () => { ); }; - // Locked-in slot-to-hue mapping. Parameterized across all 10 slots - // so a future tweak to `LEGACY_CHART_PALETTE_TOKEN_MAP` is caught - // here instead of by a stored dashboard silently recoloring on the - // user's next reload. Order matches the HyperDX `_tokens.scss` - // categorical palette. - it.each([ - ['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'], - ])('migrates legacy %s → %s', (legacy, hue) => { + // Drives every slot off the canonical `LEGACY_CHART_PALETTE_TOKEN_MAP` + // so a future tweak to the mapping is caught here instead of by a + // stored dashboard silently recoloring on the user's next reload. + it.each(LEGACY_TO_HUE_CASES)('migrates legacy %s → %s', (legacy, hue) => { storeDashboardWithTileColor(legacy); expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ color: hue, diff --git a/packages/common-utils/src/__tests__/guards.test.ts b/packages/common-utils/src/__tests__/guards.test.ts index 889a84cae5..0e04ca17bd 100644 --- a/packages/common-utils/src/__tests__/guards.test.ts +++ b/packages/common-utils/src/__tests__/guards.test.ts @@ -4,6 +4,7 @@ import { isRawSqlSavedChartConfig, } from '@/guards'; import { + CATEGORICAL_PALETTE_TOKENS, ChartPaletteTokenSchema, DisplayType, isChartPaletteToken, @@ -177,19 +178,7 @@ describe('displayTypeRequiresSource', () => { describe('isChartPaletteToken', () => { 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) { + for (const token of CATEGORICAL_PALETTE_TOKENS) { expect(isChartPaletteToken(token)).toBe(true); } });