diff --git a/.changeset/rename-chart-palette-tokens.md b/.changeset/rename-chart-palette-tokens.md new file mode 100644 index 0000000000..cea4e38ac2 --- /dev/null +++ b/.changeset/rename-chart-palette-tokens.md @@ -0,0 +1,85 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/app': minor +'@hyperdx/api': patch +--- + +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 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, 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 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, + 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 +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. + +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: `--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 +`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 72438f54c1..4f12ed08ea 100644 --- a/agent_docs/data_viz_colors.md +++ b/agent_docs/data_viz_colors.md @@ -1,114 +1,224 @@ # 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 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 | `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-N` for heatmap density; don't reuse the heatmap + 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** — 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` | `#437eef` | +| `chart-orange` | `#efb118` | +| `chart-red` | `#ff725c` | +| `chart-cyan` | `#6cc5b0` | +| `chart-green` | `#3ca951` | +| `chart-pink` | `#ff8ab7` | +| `chart-purple` | `#a463f2` | +| `chart-light-blue` | `#97bbf5` | +| `chart-brown` | `#9c6b4e` | +| `chart-gray` | `#9498a0` | + +**Source of truth for categorical hues lives in JS.** The matching +`--color-chart-{hue}` CSS vars in both +`packages/app/src/theme/themes/hyperdx/_tokens.scss` and +`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`. + +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}`) ```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`). +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. -### JavaScript fallback (`packages/app/src/utils.ts`) +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. -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': '#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[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 what `getColorFromCSSVariable(i)` returns — on both server and +client, since categorical hues no longer round-trip through the CSS var. + +The hue-named `CHART_PALETTE_TOKENS`, `CATEGORICAL_PALETTE_TOKENS`, and +`SEMANTIC_PALETTE_TOKENS` constants live in `packages/common-utils/src/types.ts` +so the Zod schema can reference them (shared with the API). ### Reader functions (`packages/app/src/utils.ts`) These are the only functions React code should call: -| Function | Returns | -| -------------------------------- | ---------------------------------------------------- | -| `getColorProps(index, level)` | Categorical color, 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. -- 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. + +Existing stored configs keep working. The mapping: + +```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 +``` + +It preserves the HyperDX slot ordering from #2265, so HyperDX users see no +visual change. + +> **⚠️ ClickStack legacy color caveat.** Pre-rename ClickStack used a different +> slot ordering than HyperDX (`--color-chart-1` was brand blue `#437eef`, not +> brand green; `--color-chart-2` was orange, not blue; etc.). Because the +> migration map preserves HyperDX slot ordering, any ClickStack dashboard saved +> via #2265 will visually shift: stored `chart-1` flips from brand blue to +> Observable green, `chart-2` flips from orange to blue, and so on. We chose +> this trade-off deliberately over branching the legacy map by active theme: +> `LEGACY_CHART_PALETTE_TOKEN_MAP` lives in `common-utils` (shared with the +> API), and migration is one-shot persisted on next save — theme-branching would +> couple common-utils to browser DOM state and still produce wrong results for +> users whose active theme changed since the original pick. Affected users can +> manually re-pick the desired hue via the (now hue-labeled) color picker. Use +> `chart-info` semantic if you need the brand-primary appearance. + +**`ChartPaletteTokenSchema` itself stays strict** (a plain `z.enum`). Wrapping +it in `z.preprocess` would force the schema's `z.input` type to `unknown`, which +poisons `validateRequest`'s `req.body` inference in the API package all the way +up to `Dashboard.tiles[i].config.color`. Strict input/output equality is more +important than a one-line runtime migration buried in the schema. + +Migration happens at two complementary layers instead: + +1. **Fetch-time _and_ write-time** — `normalizeDashboardTileColors` in + `packages/app/src/dashboard.ts` walks every tile and rewrites any legacy + `config.color` to its hue-named equivalent via `resolveChartPaletteToken`. It + runs on every read (`useDashboards` / `fetchLocalDashboards`) and on every + write (`useUpdateDashboard` / `useCreateDashboard`). The write-time pass is + what lets JSON imports (`DBDashboardImportPage`), presets, and + MCP-constructed payloads pass the strict server-side validator, and it + converges the DB-side data on next save instead of leaving legacy tokens in + storage forever. +2. **Render-time** — `DBNumberChart` and `ColorSwatchInput` also call + `resolveChartPaletteToken` as belt-and-suspenders for tiles constructed in + memory between fetch and save (e.g. `ChartEditor` form state, unit-test + fixtures, hand-rolled `Tile` literals). + +`LEGACY_CHART_PALETTE_TOKEN_MAP` and `resolveChartPaletteToken` live in +`packages/common-utils/src/types.ts` next to the enum. ### Heatmap palette (component-local) @@ -119,9 +229,9 @@ Internals worth knowing: - Selected at the call site by `useMantineColorScheme()` and `colorScheme === 'light' ? lightPalette : darkPalette`. -These are **scheme-aware, not brand-aware**: HyperDX dark and ClickStack -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** @@ -133,55 +243,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 `--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. + `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: - -```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', -]; -``` +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. -It renders `AllChartColors`, `BarChartPreview`, `LineChartPreview`, -`SemanticColorsPreview`, and `AccessibilityCheck`. Run storybook in the -`app` package to inspect both schemes side by side. +The number-tile color picker (`ColorSwatchInput.stories.tsx`) renders the same +tokens through the user-facing picker UI. ## How to consume (recipes) ### Multi-series time-series chart `ChartUtils.tsx → setLineColors` already wires the categorical palette per -series via `getColorProps(index, level)` in -`packages/app/src/ChartUtils.tsx`. **You should not have to think about -chart colors when adding a new chart that goes through `seriesToTimeSeries` -/ `setLineColors`** — they handle it. +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: @@ -195,9 +281,26 @@ 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") + +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 @@ -209,8 +312,8 @@ 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). +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: @@ -218,9 +321,15 @@ If you're styling a DOM element with regular CSS, prefer the var directly: ``` -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. ### Heatmap @@ -232,15 +341,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); @@ -250,125 +359,143 @@ 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. Semantic +`success` and `info` also reuse categorical hues on both brands. + +### HyperDX -- 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. +- `--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 and sidebar gradient, not via chart semantic colors. -### ClickStack (blue-first) +### 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. +- 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 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 -- 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 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 -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 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 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. -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 - color shows up in the design-tokens story. -6. If it should override based on a label / status string, extend +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-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 + `SEMANTIC_PALETTE_TOKENS` slice will pick it up automatically) in + `common-utils/src/types.ts`. +5. Add a `getChartColorPending()` reader in `utils.ts` that calls + `getSemanticChartColor('--color-chart-pending', 'pending')`. +6. Update `ChartColors.stories.tsx` `SEMANTIC_CHART_COLORS` so the new color + shows up in the design-tokens story. +7. Add a `'chart-pending': 'Pending'` entry to `TOKEN_LABELS` in + `ColorSwatchInput.tsx`. +8. If it should override based on a label / status string, extend `getLevelColor` / `logLevelColor` accordingly. ### A new heatmap palette The current arrays were tuned to (a) be visible on the chart's dark/light -background, (b) avoid red at the high end so error overlays stay -readable, and (c) follow a perceptually monotonic luminance ramp. -Don't add a new palette without those three properties verified — both -"physically dim" and "perceptually noisy" gradients hurt readability. +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 ```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. -- **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). +- **Hex / keyword**: bypasses theme switching. Dark/light won't react, and any + future per-theme tweak won't propagate. +- **Raw Mantine**: bypasses semantic mapping. `green.5` is not the same as + `--color-chart-success` once we tweak the brand palette. +- **Importing palette objects**: `CATEGORICAL_HEX_BY_TOKEN` and + `SEMANTIC_CHART_PALETTE` are module-private. The exported surface is the + reader functions, the token enums, and `COLORS` (the SSR fallback array — + prefer `getColorProps` / `getColorFromCSSToken` over indexing it directly). ## Pre-merge checklist for chart-touching PRs -- [ ] Toggled HyperDX ↔ ClickStack theme — series colors change as - expected (slot 1 swaps green ↔ blue). -- [ ] 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 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 + 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 **both** places + (`_chart-categorical-tokens.scss` shared partial, + `CATEGORICAL_HEX_BY_TOKEN` in `utils.ts`). +- [ ] If you added or changed a semantic hex, changed it in **both** places + (`chart-semantic-tokens` mixin in `_chart-categorical-tokens.scss`, + `SEMANTIC_CHART_PALETTE.{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 | +| --------------------------------------------------- | -------------------------------------------------------------------------------- | +| 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` | +| 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/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 415c95aa9d..6cafc53da3 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -80,6 +80,81 @@ 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'); + }); + + // 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. + // + // 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({ + 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).toBeDefined(); + expect(fromList.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 8ac2ea2f30..71112b756a 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -3,6 +3,8 @@ import { DashboardWithoutIdSchema, PresetDashboard, PresetDashboardFilterSchema, + resolveChartPaletteToken, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; @@ -29,6 +31,32 @@ 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. 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, +) => { + req.body = walkRawDashboardTileColors(req.body, current => { + const resolved = resolveChartPaletteToken(current); + return resolved ?? current; + }); + next(); +}; + router.get('/', async (req, res, next) => { try { const { teamId } = getNonNullUserWithTeam(req); @@ -43,6 +71,7 @@ router.get('/', async (req, res, next) => { router.post( '/', + migrateLegacyDashboardTileColors, validateRequest({ body: DashboardWithoutIdSchema, }), @@ -63,6 +92,7 @@ router.post( router.patch( '/:id', + migrateLegacyDashboardTileColors, validateRequest({ params: z.object({ id: objectIdSchema, 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 eb8c096792..79ac66ca7e 100644 --- a/packages/api/src/tasks/provisionDashboards/index.ts +++ b/packages/api/src/tasks/provisionDashboards/index.ts @@ -1,6 +1,8 @@ import { DashboardWithoutId, DashboardWithoutIdSchema, + resolveChartPaletteToken, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import fs from 'fs'; import path from 'path'; @@ -12,6 +14,20 @@ import type { HdxTask } from '@/tasks/types'; import { ProvisionDashboardsTaskArgs } from '@/tasks/types'; import logger from '@/utils/logger'; +// 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[] { let files: string[]; try { @@ -24,10 +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')); + 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/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 1ef8c840a5..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, @@ -675,7 +701,15 @@ 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 `--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. */} + {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 e2c1081e8d..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 } 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: COLORS[0], + color: SEMANTIC_INFO_HEX, dataKey: 'info', currentPeriodKey: 'info', previousPeriodKey: 'info (previous)', @@ -315,7 +323,7 @@ describe('ChartUtils', () => { isDashed: false, }, { - color: COLORS[0], + 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 new file mode 100644 index 0000000000..d5850df84e --- /dev/null +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -0,0 +1,289 @@ +// 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(), +})); + +// 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((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, + normalizeRawDashboardTileColors, + useCreateDashboard, + useUpdateDashboard, +} 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(); + mutationFnCalls.length = 0; +}); + +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.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: hue }); + }); + + 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('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).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 }); + }); +}); + +// 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('preserves unresolvable color through POST so the server can surface a clear schema error', 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]; + 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 () => { + 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 5e5ba38773..215c848345 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -35,6 +35,96 @@ 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: [], + }, + ]), + ); + }; + + // 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: hue, + }); + }); + + it('passes through hue-named tokens unchanged', () => { + storeDashboardWithTileColor('chart-orange'); + expect(fetchLocalDashboards()[0].tiles[0].config).toMatchObject({ + color: 'chart-orange', + }); + }); + + 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'); + 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 bedea9c851..5d8eeb68aa 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'; @@ -1210,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-1' ? '#custom-green' : '', - } 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-1')).toBe('#custom-green'); + expect(getColorFromCSSToken('chart-blue')).toBe(COLORS[0]); + expect(getComputedStyleSpy).not.toHaveBeenCalled(); }); it('returns the CSS variable value for semantic tokens when provided', () => { @@ -1228,40 +1238,33 @@ describe('getColorFromCSSToken', () => { expect(getColorFromCSSToken('chart-success')).toBe('#theme-green'); }); - it('falls back to COLORS[0] for chart-1 when the CSS variable is empty', () => { - jest.spyOn(global, 'getComputedStyle').mockReturnValue({ - getPropertyValue: () => '', - } as unknown as CSSStyleDeclaration); - - expect(getColorFromCSSToken('chart-1')).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 their designated palette colors. - // These hex values are the CHART_PALETTE constants used in paletteTokenSSRFallback. - expect(getColorFromCSSToken('chart-success')).toBe('#00c28a'); + // Defaults to HyperDX in jsdom because the document has no + // theme-clickstack class. + expect(getColorFromCSSToken('chart-success')).toBe('#3ca951'); 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]); }); - 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. - // 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]); }); + }); - for (let i = 1; i <= 10; i++) { - expect(getColorFromCSSToken(`chart-${i}` as any)).toBe(COLORS[i - 1]); - } + 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/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/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..224cf16c01 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'), @@ -135,6 +139,30 @@ describe('ColorSwatchInput', () => { } }); + it('migrates a legacy chart-1..10 value to the matching hue swatch', async () => { + // 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( + , + ); + + 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(); @@ -144,7 +172,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 +181,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 +210,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..5b82741f6b 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'; @@ -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', @@ -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. The fetch normalizer in `dashboard.ts` usually + // heals stored data before it reaches us, but in-memory tiles or + // preset configs can still arrive with legacy values. Truly unknown + // values 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..f2bc1869bc 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,18 @@ 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 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 + ? getColorFromCSSToken(resolvedColorToken) + : undefined; const toolbarItemsMemo = useMemo(() => { const allToolbarItems = []; diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 9d2c8c31e6..683cf0f4ac 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,19 @@ const PatternTrendChart = ({ isAnimationActive={false} dataKey="count" stackId="a" - fill={color || COLORS[0]} + // `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/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/components/__tests__/DBNumberChart.test.tsx b/packages/app/src/components/__tests__/DBNumberChart.test.tsx index 8bdc539217..a1d2370d51 100644 --- a/packages/app/src/components/__tests__/DBNumberChart.test.tsx +++ b/packages/app/src/components/__tests__/DBNumberChart.test.tsx @@ -348,5 +348,25 @@ 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', () => { + // 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, + }; + + 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/dashboard.ts b/packages/app/src/dashboard.ts index 0a4851c4ce..8e52d3813a 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -4,8 +4,10 @@ import { DashboardContainer, DashboardFilter, Filter, + resolveChartPaletteToken, SavedChartConfig, SearchConditionLanguage, + walkRawDashboardTileColors, } from '@hyperdx/common-utils/dist/types'; import { notifications } from '@mantine/notifications'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -46,11 +48,76 @@ export type Dashboard = { const localDashboards = createEntityStore('hdx-local-dashboards'); -async function fetchDashboards(): Promise { +/** + * 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 + * downstream consumer (renderers, the color picker, save mutations) + * sees the canonical hue tokens that `ChartPaletteTokenSchema` + * 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. + * + * 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; + 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. 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 { + return walkRawDashboardTileColors(input, migrateOrPreserveColor); +} + +/** + * 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(); + return localDashboards.getAll().map(normalizeDashboardTileColors); } - return hdxServer('dashboards').json(); + const dashboards = await hdxServer('dashboards').json(); + return dashboards.map(normalizeDashboardTileColors); } export function useUpdateDashboard() { @@ -60,14 +127,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: () => { @@ -81,12 +149,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: () => { @@ -143,7 +212,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]); @@ -155,7 +229,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); @@ -196,7 +274,7 @@ export function useDashboard({ } export function fetchLocalDashboards(): Dashboard[] { - return localDashboards.getAll(); + return localDashboards.getAll().map(normalizeDashboardTileColors); } export function getLocalDashboardTags(): string[] { diff --git a/packages/app/src/theme/ChartColors.stories.tsx b/packages/app/src/theme/ChartColors.stories.tsx index 56e17a2568..bdd50500bd 100644 --- a/packages/app/src/theme/ChartColors.stories.tsx +++ b/packages/app/src/theme/ChartColors.stories.tsx @@ -1,19 +1,21 @@ import React from 'react'; import { + CATEGORICAL_PALETTE_TOKENS, COLORS, getChartColorError, + getChartColorInfo, 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 +23,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 = [ @@ -44,6 +47,11 @@ const SEMANTIC_CHART_COLORS = [ hex: getChartColorError(), label: 'Error (Red)', }, + { + name: 'color-chart-info', + hex: getChartColorInfo(), + label: 'Info (Blue)', + }, ]; const story = { @@ -124,6 +132,18 @@ export const AllChartColors = () => (

Semantic Chart Colors

+

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