v1.0

chartTheme

Code

ts
import chartTheme from '@/components/data/chartTheme';

src/components/data/chartTheme.ts

AI prompt

text
Build a shared chart theming module in React + TypeScript: one place for chart colours and number formats, so every chart in a dashboard uses the same palette and adapts to light and dark mode. Charts use it with Recharts. It is a module of hooks, pure functions and formatters, with no UI of its own.

## Dark mode
Both hooks read the active scheme from your theme state: the `.dark` class on <html>, or a theme context that toggles it. Each returns a plain object of hex strings to pass as Recharts props.

## `chartPalette(dark: boolean)` and the `useChartPalette()` hook
Returns `{ dark, categorical, ordinal, sequential, diverging, muted, surface, grid, axis, ink, inkSecondary }`:
- `categorical`, eight hues in a FIXED order. Light: #2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300, #4a3aa7, #e34948. Dark: #3987e5, #d95926, #199e70, #c98500, #d55181, #008300, #9085e9, #e66767.
  - Series take slots in the order they are DECLARED, never by rank, so filtering one out does not repaint the rest.
  - The order is also the colour-blind-safety mechanism: each adjacent pair stays distinguishable under colour-vision-deficiency simulation.
  - Three light hues fall under 3:1 contrast on white, so charts carry a legend and a table view, and identity never rests on colour alone.
  - A scatter, where every pair of series can touch, uses only the first three slots.
  - Past eight series, fold the rest into "Other". Never generate a ninth colour.
- `ordinal` (a funnel's stage ramp, one hue). Light: #104281, #1c5cab, #2a78d6, #5598e7, #86b6ef. Dark: #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf.
- `sequential` (heatmap, near zero → most). Light: #e8f1fd, #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf, #184f95, #0d366b. Dark: #26324a, #184f95, #1c5cab, #256abf, #3987e5, #6da7ec, #9ec5f4, #cde2fb.
- `diverging`: negative #e34948 / dark #e66767, positive #2a78d6 / dark #3987e5, mid #e2e8f0 / dark #475569 (a grey midpoint that reads as "nothing").
- Chrome, light / dark:
  - `muted` #94a3b8 / #64748b: the de-emphasis grey for context series and sparkline history.
  - `surface` #ffffff / #1e293b: the card colour, used for 2px gaps between touching marks.
  - `grid` #e2e8f0 / #334155.
  - `axis` #64748b / #94a3b8.
  - `ink` #0f172a / #f1f5f9 and `inkSecondary` #475569 / #cbd5e1.
- Export `type ChartPalette = ReturnType<typeof chartPalette>`.

## `useChartTheme()`, the older named-series theme
Returns `{ dark, axis, grid, tooltip, series }`:
- `axis` #6b7280 / dark #9ca3af, and `grid` #e5e7eb / dark #374151.
- `tooltip`: a CSSProperties object for Recharts `contentStyle`. Background #ffffff / #1f2937, border `1px solid` in the grid colour, radius 0.5rem, text #111827 / #f3f4f6, fontSize 12px.
- `series`: spend #f59e0b, deposit #10b981, registrations #6366f1, ftd #3b82f6, positive #10b981, negative #ef4444, retention #8b5cf6, retentionD30 #ec4899.

## Formatters (en-US)
- `compactCurrency`: USD, compact notation, max one decimal, e.g. "$184.3K".
- `fullCurrency`: USD with no decimals, e.g. "$184,320".
- `compactNumber`: compact notation, max one decimal, e.g. "1.2K".
- `percent`: `${v.toFixed(0)}%`.

## Companion helpers for cartesian charts
- `type ChartSeries = { key: string; label: string; slot?: number }`.
- `seriesColor(palette, series, index)` returns `categorical[series.slot ?? index]`, capped at the last slot. A chart whose series come and go (a filter, a toggle) should pin `slot` so each colour stays with its series.
- `axisProps(palette)` returns `{ tick: { fontSize: 11, fill: axis }, stroke: grid, tickLine: false }`: solid hairlines and muted ticks, spread onto every XAxis and YAxis.

## Demo
A page of swatch rows for each ramp (categorical, ordinal, sequential, diverging) in the current mode, with a light/dark toggle, and a small Recharts line chart of three series using `seriesColor` and `axisProps`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: marketing-stats (96S1), verbatim. */

import { useTheme } from '@/contexts/ThemeContext';

// One place for the dashboard's chart colors so every chart shares the same
// categorical palette and adapts axes/grid/tooltip to light vs dark. Hues are
// deliberately distinct (amber / emerald / indigo / blue) and all read on both
// a white and a near-black surface.
export function useChartTheme() {
  const { theme } = useTheme();
  const dark = theme === 'dark';
  return {
    dark,
    axis: dark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
    grid: dark ? '#374151' : '#e5e7eb', // gray-700 / gray-200
    tooltip: {
      backgroundColor: dark ? '#1f2937' : '#ffffff', // gray-800 / white
      border: `1px solid ${dark ? '#374151' : '#e5e7eb'}`,
      borderRadius: '0.5rem',
      color: dark ? '#f3f4f6' : '#111827',
      fontSize: '12px',
    } as React.CSSProperties,
    series: {
      spend: '#f59e0b', // amber-500
      deposit: '#10b981', // emerald-500
      registrations: '#6366f1', // indigo-500
      ftd: '#3b82f6', // blue-500
      positive: '#10b981', // emerald-500
      negative: '#ef4444', // red-500
      retention: '#8b5cf6', // violet-500
      retentionD30: '#ec4899', // pink-500
    },
  };
}

/**
 * THE CHART PALETTE — validated, by role.
 *
 * `categorical` is eight hues in a FIXED order: series take slots in the
 * order they are declared, never by rank, so filtering a series out never
 * repaints the survivors. The order is the colour-blind-safety mechanism, not
 * decoration — each adjacent pair clears ΔE 8 under every CVD simulation.
 * Checked with the dataviz skill's validator against this kit's own surfaces
 * (white / slate-800), light and dark:
 *
 *   light  worst adjacent CVD ΔE 9.1, normal-vision 19.6
 *   dark   worst adjacent CVD ΔE 8.4, normal-vision 19.3
 *
 * Three light hues (aqua, yellow, magenta) sit under 3:1 on white, so every
 * chart built on these carries a legend and a table view — identity never
 * rests on colour alone. A scatter, where EVERY pair of series can touch,
 * caps at the first three slots (all-pairs validated in both modes).
 *
 * `ordinal` is the funnel's stage ramp (one hue, visible steps, the end
 * nearest the surface still 2:1); `sequential` is the heatmap's (lightest =
 * near zero, allowed to recede); `diverging` is blue ↔ red with a grey
 * midpoint that reads as "nothing". A ninth series is never a generated
 * hue — fold it into "Other".
 */
const CATEGORICAL = {
  light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'],
  dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'],
};
const ORDINAL = {
  light: ['#104281', '#1c5cab', '#2a78d6', '#5598e7', '#86b6ef'],
  dark: ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf'],
};
const SEQUENTIAL = {
  // near zero → most
  light: ['#e8f1fd', '#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b'],
  dark: ['#26324a', '#184f95', '#1c5cab', '#256abf', '#3987e5', '#6da7ec', '#9ec5f4', '#cde2fb'],
};

/** The palette for one mode, plus the chrome every chart shares. */
export function chartPalette(dark: boolean) {
  const mode = dark ? 'dark' : 'light';
  return {
    dark,
    categorical: CATEGORICAL[mode],
    ordinal: ORDINAL[mode],
    sequential: SEQUENTIAL[mode],
    diverging: { negative: dark ? '#e66767' : '#e34948', positive: dark ? '#3987e5' : '#2a78d6', mid: dark ? '#475569' : '#e2e8f0' },
    /** The de-emphasis grey: context series, sparkline history. */
    muted: dark ? '#64748b' : '#94a3b8',
    /** What a 2px gap between touching marks is drawn in — the card surface. */
    surface: dark ? '#1e293b' : '#ffffff',
    grid: dark ? '#334155' : '#e2e8f0',
    axis: dark ? '#94a3b8' : '#64748b',
    ink: dark ? '#f1f5f9' : '#0f172a',
    inkSecondary: dark ? '#cbd5e1' : '#475569',
  };
}

export type ChartPalette = ReturnType<typeof chartPalette>;

/** The palette for the active colour scheme. */
export function useChartPalette(): ChartPalette {
  const { theme } = useTheme();
  return chartPalette(theme === 'dark');
}

export const compactCurrency = (v: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(v);

export const fullCurrency = (v: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);

export const compactNumber = (v: number) =>
  new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(v);

export const percent = (v: number) => `${v.toFixed(0)}%`;