diff --git a/.changeset/shared-chart-card.md b/.changeset/shared-chart-card.md
new file mode 100644
index 0000000000..e7b225fe38
--- /dev/null
+++ b/.changeset/shared-chart-card.md
@@ -0,0 +1,9 @@
+---
+'@hyperdx/app': patch
+---
+
+Introduce a shared `ChartCard` component that gives standalone charts the same
+card treatment as custom dashboard tiles (bordered surface + full-bleed header
+divider). Migrate the Service Dashboards (HTTP, Database, Errors, endpoint and
+DB-query side panels) and the ClickHouse page from the old `ChartBox` wrapper to
+`ChartCard` so chart surfaces look consistent across the app.
diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md
index acb9ff411c..0b54ce1f08 100644
--- a/agent_docs/code_style.md
+++ b/agent_docs/code_style.md
@@ -159,6 +159,49 @@ The variant → token mapping is centralized in `packages/app/src/theme/themes/s
**Title copy**: Treat `title` as a short headline (like `Title` in the UI). Do **not** end it with a period. Use `description` for full sentences, which should use normal punctuation including a trailing period when appropriate. Match listing pages (e.g. dashboards and saved searches use parallel phrasing such as “No matching … yet” / “No … yet” without dots).
+### Chart cards (ChartCard)
+
+**Use `ChartCard` (`@/components/charts/ChartCard`) to wrap a chart in a card.**
+It is a bordered surface with the same header treatment as a custom dashboard
+tile (a full-bleed divider under the title). It replaces the older `ChartBox`;
+don't hand-roll a bordered `
`/`` around a chart.
+
+`ChartCard` renders the card **chrome only**. The header divider is drawn only
+when a descendant renders a `ChartContainer` with a `title` (or `toolbarItems`) —
+`ChartCard` supplies the `ChartContainerCardHeaderProvider` that switches that
+header into card mode — so put a chart that renders a `ChartContainer` inside it
+(`DBTimeChart`, `DBTableChart`, `DBHeatmapChart`, `DBListBarChart`, …). Content
+with its own heading (e.g. a bespoke table card) should still route that heading
+through a titled `ChartContainer` rather than a bare `Text`, so it gets the same
+card header — divider and top padding included — instead of sitting flush against
+the top border. The tile-level controls (fullscreen, line/bar display switcher,
+kebab menu) belong to dashboard tiles and are intentionally **not** part of
+`ChartCard`.
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `children` | `ReactNode` | The chart, usually a `DB*Chart` (or a titled `ChartContainer`) |
+| `style` | `CSSProperties` | Sizing/overflow override — pass a fixed `height`, or `flex: 1; height: 100%` to fill a flex row (`paddingInline` is pinned to keep the divider aligned) |
+| `data-testid` | `string` | Test hook |
+
+```tsx
+// ✅ GOOD — shared card chrome, consistent with dashboard tiles
+
+
+
+
+// ❌ BAD — hand-rolled card that drifts from the dashboard look
+
+
+
+```
+
+**Give it a height.** `ChartCard` is `width: 100%` and fills its parent, so the
+parent (or a `style={{ height }}`) must define the height. For equal-width
+side-by-side charts (e.g. the RED row) use
+`style={{ flex: 1, minWidth: 0, minHeight: 0, height: '100%' }}` inside a
+`Flex`. See the `Charts/ChartCard` Storybook stories for the variants.
+
## Semantic design tokens (prefer over raw Mantine colors)
The UI is built with **Mantine components**, but **colors and surfaces** should follow the **semantic CSS custom properties** in our themes (`--color-*`, etc.), not ad-hoc Mantine palette values. Those tokens are defined in `packages/app/src/theme/themes/**/_tokens.scss`, align with a **Click UI**–style system, and keep HyperDX and ClickStack visually consistent. They are the path toward a shared design system even while Mantine remains the component layer.
diff --git a/packages/app/src/ClickhousePage.tsx b/packages/app/src/ClickhousePage.tsx
index 1ad6df4fad..fc626ff1dd 100644
--- a/packages/app/src/ClickhousePage.tsx
+++ b/packages/app/src/ClickhousePage.tsx
@@ -38,7 +38,8 @@ import { PageLayout } from '@/components/PageLayout';
import { TimePicker } from '@/components/TimePicker';
import { withAppNav } from '@/layout';
-import { ChartBox } from './components/ChartBox';
+import { ChartCard } from './components/charts/ChartCard';
+import ChartContainer from './components/charts/ChartContainer';
import DBHeatmapChart from './components/DBHeatmapChart';
import { DBSqlRowTable } from './components/DBRowTable';
import DBTableChart from './components/DBTableChart';
@@ -70,7 +71,7 @@ function InfrastructureTab({
return (
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -250,7 +251,7 @@ function InfrastructureTab({
}}
onTimeRangeSelect={onTimeRangeSelect}
/>
-
+
);
@@ -355,7 +356,7 @@ function InsertsTab({
return (
-
+
@@ -387,10 +388,10 @@ function InsertsTab({
config={insertsPerTableConfig}
onTimeRangeSelect={onTimeRangeSelect}
/>
-
+
-
+
-
+
-
+
@@ -484,7 +485,7 @@ function InsertsTab({
selectGroupBy: false,
}}
/>
-
+
);
@@ -665,7 +666,7 @@ function ClickhousePage() {
-
+
-
+
-
+
-
+
-
+
-
+
-
-
- Slowest Queries
-
- {
- return (
-
- );
- }}
- config={{
- select: `event_time, query_kind,
+
+
+ {
+ return (
+
+ );
+ }}
+ config={{
+ select: `event_time, query_kind,
read_rows,
formatReadableSize(memory_usage) as memory_usage,
query_duration_ms,
query`,
- dateRange: searchedTimeRange,
- from,
- where: `(
+ dateRange: searchedTimeRange,
+ from,
+ where: `(
type='ExceptionWhileProcessing' OR type='QueryFinish'
)`,
- timestampValueExpression: 'event_time',
- connection,
- orderBy: [
- {
- valueExpression: 'query_duration_ms',
- ordering: 'DESC',
- },
- ],
- filters: [
- ...filters,
- {
- type: 'sql_ast',
- operator: '=',
- left: 'query_kind',
- right: `'Select'`,
- },
- ],
- limit: { limit: 100 },
- }}
- />
-
+ timestampValueExpression: 'event_time',
+ connection,
+ orderBy: [
+ {
+ valueExpression: 'query_duration_ms',
+ ordering: 'DESC',
+ },
+ ],
+ filters: [
+ ...filters,
+ {
+ type: 'sql_ast',
+ operator: '=',
+ left: 'query_kind',
+ right: `'Select'`,
+ },
+ ],
+ limit: { limit: 100 },
+ }}
+ />
+
+
diff --git a/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx b/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx
index 01a6ce893d..e09bcb7135 100644
--- a/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx
+++ b/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx
@@ -10,7 +10,7 @@ import { Grid } from '@mantine/core';
import { IconFilter, IconTable } from '@tabler/icons-react';
import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import DisplaySwitcher from '@/components/charts/DisplaySwitcher';
import DBListBarChart from '@/components/DBListBarChart';
import DBTableChart from '@/components/DBTableChart';
@@ -305,7 +305,7 @@ function DatabaseTab({
return (
-
+
{source && totalTimePerQueryConfig && (
)}
-
+
-
+
{source && totalThroughputPerQueryConfig && (
)}
-
+
-
+
{source &&
expressions &&
(chartType === 'list' ? (
@@ -508,7 +508,7 @@ function DatabaseTab({
}}
/>
))}
-
+
);
diff --git a/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx b/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx
index f240818756..a6ccb10c13 100644
--- a/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx
+++ b/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx
@@ -7,7 +7,7 @@ import {
import { Grid } from '@mantine/core';
import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import { DBTimeChart } from '@/components/DBTimeChart';
import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput';
import { useServiceDashboardExpressions } from '@/serviceDashboard';
@@ -33,7 +33,7 @@ function ErrorsTab({
return (
-
+
{source && expressions && (
)}
-
+
);
diff --git a/packages/app/src/ServicesDashboardPage/HttpTab.tsx b/packages/app/src/ServicesDashboardPage/HttpTab.tsx
index bc2ccdae36..229d58a66c 100644
--- a/packages/app/src/ServicesDashboardPage/HttpTab.tsx
+++ b/packages/app/src/ServicesDashboardPage/HttpTab.tsx
@@ -19,7 +19,7 @@ import {
INTEGER_NUMBER_FORMAT,
MS_NUMBER_FORMAT,
} from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import DisplaySwitcher from '@/components/charts/DisplaySwitcher';
import DBHistogramChart from '@/components/DBHistogramChart';
import DBListBarChart from '@/components/DBListBarChart';
@@ -73,7 +73,7 @@ export function EndpointLatencyChart({
);
return (
-
+
{source &&
expressions &&
(latencyChartType === 'line' ? (
@@ -172,7 +172,7 @@ export function EndpointLatencyChart({
}}
/>
))}
-
+
);
}
@@ -396,7 +396,7 @@ function HttpTab({
return (
-
@@ -423,10 +423,10 @@ function HttpTab({
disableDrillDown
/>
)}
-
+
-
@@ -456,10 +456,10 @@ function HttpTab({
}}
/>
)}
-
+
-
+
{source && expressions && (
)}
-
+
{source && isTraceSource(source) && (
@@ -567,7 +567,7 @@ function HttpTab({
)}
-
@@ -690,7 +690,7 @@ function HttpTab({
}}
/>
)}
-
+
);
diff --git a/packages/app/src/components/ChartBox.tsx b/packages/app/src/components/ChartBox.tsx
deleted file mode 100644
index cd1ce5802f..0000000000
--- a/packages/app/src/components/ChartBox.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Box, BoxComponentProps } from '@mantine/core';
-
-export function ChartBox({
- children,
- style,
- 'data-testid': dataTestId,
-}: {
- children: React.ReactNode;
- style?: BoxComponentProps['style'];
- 'data-testid'?: string;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx
index 37a7541459..aa9cd8147d 100644
--- a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx
+++ b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx
@@ -12,7 +12,7 @@ import { IconServer } from '@tabler/icons-react';
import { IsolatedChartSyncProvider } from '@/chartSync';
import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
@@ -99,7 +99,7 @@ export default function ServiceDashboardDbQuerySidePanel({
-
+
{source && expressions && (
)}
-
+
-
+
{source && expressions && (
)}
-
+
{source && (
diff --git a/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx b/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx
index 61bf3cb73d..a6d1c7b434 100644
--- a/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx
+++ b/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx
@@ -5,7 +5,7 @@ import {
} from '@hyperdx/common-utils/dist/types';
import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import DBListBarChart from '@/components/DBListBarChart';
import { useJsonColumns } from '@/hooks/useMetadata';
import {
@@ -89,7 +89,7 @@ export default function ServiceDashboardEndpointPerformanceChart({
}
return (
-
+
{source && (
)}
-
+
);
}
diff --git a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx
index e1f23217b7..638f4443c7 100644
--- a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx
+++ b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx
@@ -15,7 +15,7 @@ import {
ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
INTEGER_NUMBER_FORMAT,
} from '@/ChartUtils';
-import { ChartBox } from '@/components/ChartBox';
+import { ChartCard } from '@/components/charts/ChartCard';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import ServiceDashboardEndpointPerformanceChart from '@/components/ServiceDashboardEndpointPerformanceChart';
@@ -106,7 +106,7 @@ export default function ServiceDashboardEndpointSidePanel({
-
+
{source && expressions && (
)}
-
+
-
+
{source && expressions && (
)}
-
+
-
- {title}
- (Slower than {roundedP95}ms)
-
- {isLoading && !data ? (
-
+ ) : (
+ source &&
+ expressions && (
- >
- )
- )}
-
+ )
+ )}
+
+
);
}
diff --git a/packages/app/src/components/charts/ChartCard.stories.tsx b/packages/app/src/components/charts/ChartCard.stories.tsx
new file mode 100644
index 0000000000..080dc04a45
--- /dev/null
+++ b/packages/app/src/components/charts/ChartCard.stories.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { Box, Flex, SegmentedControl, Text } from '@mantine/core';
+import type { Meta } from '@storybook/nextjs';
+
+import { ChartCard } from './ChartCard';
+import ChartContainer from './ChartContainer';
+
+const meta = {
+ title: 'Charts/ChartCard',
+ component: ChartCard,
+} satisfies Meta;
+
+export default meta;
+
+/**
+ * A dependency-free stand-in for a real chart body so the stories render the
+ * card chrome (border + header divider) without wiring up ClickHouse queries.
+ */
+function FakeChart({ color = 'var(--color-chart-blue)' }: { color?: string }) {
+ const points = [4, 18, 10, 26, 14, 30, 20, 34, 22, 40, 28, 44];
+ const max = Math.max(...points);
+ const d = points
+ .map((p, i) => {
+ const x = (i / (points.length - 1)) * 100;
+ const y = 100 - (p / max) * 100;
+ return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
+ })
+ .join(' ');
+ return (
+
+ );
+}
+
+/**
+ * The common case: a single titled chart. `ChartCard` supplies the card-mode
+ * header, so the title sits above a full-bleed divider inside a bordered
+ * surface — the same treatment as a custom dashboard tile.
+ */
+export const Default = () => (
+
+
+
+
+
+
+
+);
+
+/**
+ * Header actions (toolbar items) render right-aligned on the divider row. Here a
+ * Rate/Vol toggle mirrors the Errors chart on the search page.
+ */
+export const WithToolbar = () => {
+ const [mode, setMode] = React.useState('rate');
+ return (
+
+
+ ,
+ ]}
+ >
+
+
+
+
+ );
+};
+
+/**
+ * The RED row from the trace search results: three equal-width cards under a
+ * shared height. Each card fills its column via `flex: 1; height: 100%`.
+ */
+export const RedRow = () => {
+ const cardStyle = { flex: 1, minWidth: 0, minHeight: 0, height: '100%' };
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/**
+ * Non-chart content (e.g. a table card) still routes its heading through a
+ * titled `ChartContainer` so it gets the same card header — divider included —
+ * as the chart cards above, instead of a bare `Text` that would sit flush
+ * against the top border with no divider.
+ */
+export const TableCard = () => (
+
+
+
+
+ table rows…
+
+
+
+
+);
diff --git a/packages/app/src/components/charts/ChartCard.tsx b/packages/app/src/components/charts/ChartCard.tsx
new file mode 100644
index 0000000000..8d6238a102
--- /dev/null
+++ b/packages/app/src/components/charts/ChartCard.tsx
@@ -0,0 +1,76 @@
+import { Box } from '@mantine/core';
+
+import {
+ ChartContainerCardHeaderProvider,
+ DASHBOARD_TILE_PADDING_INLINE,
+} from './ChartContainer';
+
+export interface ChartCardProps {
+ children: React.ReactNode;
+ /**
+ * Sizing/overflow override merged over the card defaults. Kept as plain
+ * `CSSProperties` (not Mantine's `MantineStyleProp`) because the defaults are
+ * object-spread — a resolver function or style array would be silently
+ * dropped. `paddingInline` is intentionally not honored here (see below).
+ */
+ style?: React.CSSProperties;
+ 'data-testid'?: string;
+}
+
+/**
+ * Card wrapper that gives a chart the same look as a tile on a custom
+ * dashboard: a bordered, rounded box whose chart header renders in "card mode"
+ * (centered title row + a full-bleed bottom divider under the title).
+ *
+ * Not dashboard-specific — use it anywhere a chart should read as a card (search
+ * page, service dashboards, the ClickHouse page, etc.).
+ *
+ * The header divider is only drawn when a descendant renders a `ChartContainer`
+ * with a `title` or `toolbarItems` (e.g. `DBTimeChart`, `DBTableChart`); this
+ * component just provides the context that switches that header into card mode.
+ * Content with its own heading should render it through a titled
+ * `ChartContainer` (rather than a bare `Text`) so it gets the same card header —
+ * divider included — and the top padding the header supplies.
+ *
+ * This is the *visual* chrome only. The dashboard tile's toolbar controls
+ * (fullscreen, line/bar display switcher, kebab menu) live on the tile itself
+ * and are intentionally not included here.
+ *
+ * Keep the horizontal padding equal to DASHBOARD_TILE_PADDING_INLINE: the card
+ * header's separator is drawn full-bleed by cancelling exactly this inset, so
+ * any other value would leave the divider misaligned with the card edges. It is
+ * re-applied after the caller `style` so an override can't silently break the
+ * divider alignment.
+ */
+export function ChartCard({
+ children,
+ style,
+ 'data-testid': dataTestId,
+}: ChartCardProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx
new file mode 100644
index 0000000000..b0b6040341
--- /dev/null
+++ b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx
@@ -0,0 +1,58 @@
+import { screen } from '@testing-library/react';
+
+import { ChartCard } from '@/components/charts/ChartCard';
+import ChartContainer from '@/components/charts/ChartContainer';
+
+// The header row (the Group wrapping the title) gets a bottom border only in
+// "card mode", which ChartCard turns on via its context provider.
+function headerRowFor(title: string) {
+ return screen.getByText(title).parentElement as HTMLElement;
+}
+
+describe('ChartCard', () => {
+ it('puts a nested titled ChartContainer into card mode so it draws the divider', () => {
+ renderWithMantine(
+
+
+
+ ,
+ );
+
+ expect(headerRowFor('Throughput').style.borderBottom).toBe('');
+ });
+
+ it('merges a caller style over the card defaults', () => {
+ renderWithMantine(
+
+
chart
+ ,
+ );
+
+ expect(screen.getByTestId('card').style.height).toBe('321px');
+ });
+
+ it('re-applies the divider padding invariant even if a caller overrides it', () => {
+ renderWithMantine(
+
+
chart
+ ,
+ );
+
+ // paddingInline is pinned to the tile inset regardless of caller style so
+ // the full-bleed header divider stays aligned with the card edges.
+ expect(screen.getByTestId('card').style.paddingInline).not.toBe('999px');
+ });
+});