diff --git a/src/design-system/composites/AreaChart/AreaChart.module.css b/src/design-system/composites/AreaChart/AreaChart.module.css
new file mode 100644
index 0000000..d098ba6
--- /dev/null
+++ b/src/design-system/composites/AreaChart/AreaChart.module.css
@@ -0,0 +1,157 @@
+.wrapper {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.svg {
+ display: block;
+ overflow: visible;
+}
+
+.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ font-size: 12px;
+ height: 120px;
+}
+
+/* Grid */
+.gridLine {
+ stroke: var(--border-subtle);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.axisLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--text-faint);
+}
+
+/* Threshold line */
+.thresholdLine {
+ stroke: var(--error);
+ stroke-width: 1.5;
+ stroke-dasharray: 5 3;
+}
+
+.thresholdLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--error);
+}
+
+/* Area + line */
+.area {
+ opacity: 0.1;
+}
+
+.line {
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+/* Crosshair */
+.crosshair {
+ stroke: var(--text-faint);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+/* Tooltip */
+.tooltip {
+ position: absolute;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ padding: 6px 10px;
+ font-size: 11px;
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+}
+
+.tooltipRow {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-bottom: 2px;
+}
+
+.tooltipRow:last-child {
+ margin-bottom: 0;
+}
+
+.tooltipDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tooltipLabel {
+ color: var(--text-muted);
+}
+
+.tooltipValue {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+/* Legend */
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding-left: 48px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.legendDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.legendLabel {
+ font-size: 11px;
+}
+
+/* Axis labels */
+.yLabel {
+ font-size: 10px;
+ color: var(--text-muted);
+ writing-mode: vertical-lr;
+ transform: rotate(180deg);
+ text-align: center;
+ position: absolute;
+ left: 4px;
+ top: 0;
+ bottom: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.xLabel {
+ text-align: center;
+ font-size: 10px;
+ color: var(--text-muted);
+ padding-left: 48px;
+}
diff --git a/src/design-system/composites/AreaChart/AreaChart.tsx b/src/design-system/composites/AreaChart/AreaChart.tsx
new file mode 100644
index 0000000..e3ea506
--- /dev/null
+++ b/src/design-system/composites/AreaChart/AreaChart.tsx
@@ -0,0 +1,254 @@
+import { useState } from 'react'
+import styles from './AreaChart.module.css'
+import {
+ computeYScale,
+ computeXScale,
+ seriesPoints,
+ seriesPath,
+ formatAxisLabel,
+ CHART_COLORS,
+ type ChartSeries,
+} from '../_chart-utils'
+
+interface Threshold {
+ value: number
+ label: string
+}
+
+interface AreaChartProps {
+ series: ChartSeries[]
+ xLabel?: string
+ yLabel?: string
+ threshold?: Threshold
+ height?: number
+ width?: number
+ className?: string
+}
+
+const Y_TICK_COUNT = 4
+const DIMS = {
+ paddingTop: 12,
+ paddingRight: 16,
+ paddingBottom: 28,
+ paddingLeft: 48,
+}
+
+export function AreaChart({
+ series,
+ xLabel,
+ yLabel,
+ threshold,
+ height = 200,
+ width = 400,
+ className,
+}: AreaChartProps) {
+ const [tooltip, setTooltip] = useState<{
+ x: number
+ y: number
+ values: { label: string; value: number; color: string }[]
+ } | null>(null)
+
+ const dims = { ...DIMS, width, height }
+ const allData = series.flatMap((s) => s.data)
+
+ if (allData.length === 0) {
+ return
No data
+ }
+
+ const { max, toY } = computeYScale(series, dims, threshold?.value)
+ const { toX } = computeXScale(series, dims)
+ const plotH = height - dims.paddingTop - dims.paddingBottom
+ const plotW = width - dims.paddingLeft - dims.paddingRight
+ const bottomY = dims.paddingTop + plotH
+
+ // Y-axis ticks
+ const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
+ Math.round((max / Y_TICK_COUNT) * i),
+ )
+
+ // X-axis ticks (first, middle, last)
+ const firstSeries = series[0]
+ const xSamples =
+ firstSeries && firstSeries.data.length > 0
+ ? [
+ firstSeries.data[0].x,
+ firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
+ firstSeries.data[firstSeries.data.length - 1].x,
+ ].filter(Boolean)
+ : []
+
+ function handleMouseMove(e: React.MouseEvent) {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const mx = e.clientX - rect.left
+ const my = e.clientY - rect.top
+
+ // Find closest x value
+ const pctX = (mx - dims.paddingLeft) / plotW
+ const values = series.map((s, i) => {
+ const idx = Math.round(pctX * (s.data.length - 1))
+ const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
+ const pt = s.data[clamped]
+ return {
+ label: s.label,
+ value: pt?.y ?? 0,
+ color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
+ }
+ })
+
+ setTooltip({ x: mx, y: my, values })
+ }
+
+ return (
+
+ {yLabel &&
{yLabel}
}
+
+
+ {/* Tooltip */}
+ {tooltip && (
+
+ {tooltip.values.map((v) => (
+
+
+ {v.label}:
+ {formatAxisLabel(v.value)}
+
+ ))}
+
+ )}
+
+ {/* Legend */}
+ {series.length > 1 && (
+
+ {series.map((s, i) => (
+
+
+ {s.label}
+
+ ))}
+
+ )}
+
+ {xLabel &&
{xLabel}
}
+
+ )
+}
diff --git a/src/design-system/composites/BarChart/BarChart.module.css b/src/design-system/composites/BarChart/BarChart.module.css
new file mode 100644
index 0000000..0f8f26f
--- /dev/null
+++ b/src/design-system/composites/BarChart/BarChart.module.css
@@ -0,0 +1,143 @@
+.wrapper {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.svg {
+ display: block;
+ overflow: visible;
+}
+
+.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ font-size: 12px;
+ height: 120px;
+}
+
+.gridLine {
+ stroke: var(--border-subtle);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.axisLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--text-faint);
+}
+
+.catLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--text-faint);
+}
+
+.bar {
+ cursor: pointer;
+ transition: opacity 0.1s;
+}
+
+.bar:hover {
+ opacity: 0.8;
+}
+
+.tooltip {
+ position: absolute;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ padding: 6px 10px;
+ font-size: 11px;
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+}
+
+.tooltipTitle {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+ font-size: 11px;
+}
+
+.tooltipRow {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-bottom: 2px;
+}
+
+.tooltipRow:last-child {
+ margin-bottom: 0;
+}
+
+.tooltipDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tooltipLabel {
+ color: var(--text-muted);
+}
+
+.tooltipValue {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding-left: 48px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.legendDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.legendLabel {
+ font-size: 11px;
+}
+
+.yLabel {
+ font-size: 10px;
+ color: var(--text-muted);
+ writing-mode: vertical-lr;
+ transform: rotate(180deg);
+ text-align: center;
+ position: absolute;
+ left: 4px;
+ top: 0;
+ bottom: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.xLabel {
+ text-align: center;
+ font-size: 10px;
+ color: var(--text-muted);
+ padding-left: 48px;
+}
diff --git a/src/design-system/composites/BarChart/BarChart.tsx b/src/design-system/composites/BarChart/BarChart.tsx
new file mode 100644
index 0000000..47549b5
--- /dev/null
+++ b/src/design-system/composites/BarChart/BarChart.tsx
@@ -0,0 +1,251 @@
+import { useState } from 'react'
+import styles from './BarChart.module.css'
+import { formatAxisLabel, CHART_COLORS } from '../_chart-utils'
+
+interface BarSeries {
+ label: string
+ data: { x: string; y: number }[]
+ color?: string
+}
+
+interface BarChartProps {
+ series: BarSeries[]
+ stacked?: boolean
+ height?: number
+ width?: number
+ xLabel?: string
+ yLabel?: string
+ className?: string
+}
+
+const PADDING = { top: 12, right: 16, bottom: 40, left: 48 }
+const Y_TICK_COUNT = 4
+const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps
+
+export function BarChart({
+ series,
+ stacked = false,
+ height = 200,
+ width = 400,
+ xLabel,
+ yLabel,
+ className,
+}: BarChartProps) {
+ const [tooltip, setTooltip] = useState<{
+ x: number
+ y: number
+ label: string
+ values: { series: string; value: number; color: string }[]
+ } | null>(null)
+
+ if (series.length === 0 || series[0].data.length === 0) {
+ return No data
+ }
+
+ // Collect all x categories (union across all series)
+ const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x))))
+ const numCats = categories.length
+
+ const plotW = width - PADDING.left - PADDING.right
+ const plotH = height - PADDING.top - PADDING.bottom
+
+ // Compute max Y
+ let maxY = 0
+ if (stacked) {
+ for (const cat of categories) {
+ const sum = series.reduce((acc, s) => {
+ const pt = s.data.find((d) => d.x === cat)
+ return acc + (pt?.y ?? 0)
+ }, 0)
+ maxY = Math.max(maxY, sum)
+ }
+ } else {
+ maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y)))
+ }
+ maxY = maxY || 1
+
+ const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
+ Math.round((maxY / Y_TICK_COUNT) * i),
+ )
+ const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH
+ const bottomY = PADDING.top + plotH
+
+ const catWidth = plotW / numCats
+ const groupGap = catWidth * BAR_GAP
+ const groupW = catWidth - groupGap
+
+ function handleMouseEnter(
+ catLabel: string,
+ mx: number,
+ my: number,
+ values: { series: string; value: number; color: string }[],
+ ) {
+ setTooltip({ x: mx, y: my, label: catLabel, values })
+ }
+
+ return (
+
+ {yLabel &&
{yLabel}
}
+
+
+ {/* Tooltip */}
+ {tooltip && (
+
+
{tooltip.label}
+ {tooltip.values.map((v) => (
+
+
+ {v.series}:
+ {formatAxisLabel(v.value)}
+
+ ))}
+
+ )}
+
+ {/* Legend */}
+ {series.length > 1 && (
+
+ {series.map((s, i) => (
+
+
+ {s.label}
+
+ ))}
+
+ )}
+
+ {xLabel &&
{xLabel}
}
+
+ )
+}
diff --git a/src/design-system/composites/LineChart/LineChart.module.css b/src/design-system/composites/LineChart/LineChart.module.css
new file mode 100644
index 0000000..83fe84b
--- /dev/null
+++ b/src/design-system/composites/LineChart/LineChart.module.css
@@ -0,0 +1,146 @@
+.wrapper {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.svg {
+ display: block;
+ overflow: visible;
+}
+
+.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ font-size: 12px;
+ height: 120px;
+}
+
+.gridLine {
+ stroke: var(--border-subtle);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.axisLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--text-faint);
+}
+
+.thresholdLine {
+ stroke: var(--error);
+ stroke-width: 1.5;
+ stroke-dasharray: 5 3;
+}
+
+.thresholdLabel {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--error);
+}
+
+.line {
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.crosshair {
+ stroke: var(--text-faint);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+.tooltip {
+ position: absolute;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ padding: 6px 10px;
+ font-size: 11px;
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+}
+
+.tooltipRow {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-bottom: 2px;
+}
+
+.tooltipRow:last-child {
+ margin-bottom: 0;
+}
+
+.tooltipDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tooltipLabel {
+ color: var(--text-muted);
+}
+
+.tooltipValue {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding-left: 48px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.legendDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.legendLabel {
+ font-size: 11px;
+}
+
+.yLabel {
+ font-size: 10px;
+ color: var(--text-muted);
+ writing-mode: vertical-lr;
+ transform: rotate(180deg);
+ text-align: center;
+ position: absolute;
+ left: 4px;
+ top: 0;
+ bottom: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.xLabel {
+ text-align: center;
+ font-size: 10px;
+ color: var(--text-muted);
+ padding-left: 48px;
+}
diff --git a/src/design-system/composites/LineChart/LineChart.tsx b/src/design-system/composites/LineChart/LineChart.tsx
new file mode 100644
index 0000000..e9a12d1
--- /dev/null
+++ b/src/design-system/composites/LineChart/LineChart.tsx
@@ -0,0 +1,233 @@
+import { useState } from 'react'
+import styles from './LineChart.module.css'
+import {
+ computeYScale,
+ computeXScale,
+ seriesPoints,
+ formatAxisLabel,
+ CHART_COLORS,
+ type ChartSeries,
+} from '../_chart-utils'
+
+interface Threshold {
+ value: number
+ label: string
+}
+
+interface LineChartProps {
+ series: ChartSeries[]
+ xLabel?: string
+ yLabel?: string
+ threshold?: Threshold
+ height?: number
+ width?: number
+ className?: string
+}
+
+const Y_TICK_COUNT = 4
+const DIMS = {
+ paddingTop: 12,
+ paddingRight: 16,
+ paddingBottom: 28,
+ paddingLeft: 48,
+}
+
+export function LineChart({
+ series,
+ xLabel,
+ yLabel,
+ threshold,
+ height = 200,
+ width = 400,
+ className,
+}: LineChartProps) {
+ const [tooltip, setTooltip] = useState<{
+ x: number
+ y: number
+ values: { label: string; value: number; color: string }[]
+ } | null>(null)
+
+ const dims = { ...DIMS, width, height }
+ const allData = series.flatMap((s) => s.data)
+
+ if (allData.length === 0) {
+ return No data
+ }
+
+ const { max, toY } = computeYScale(series, dims, threshold?.value)
+ const { toX } = computeXScale(series, dims)
+ const plotH = height - dims.paddingTop - dims.paddingBottom
+ const plotW = width - dims.paddingLeft - dims.paddingRight
+ const bottomY = dims.paddingTop + plotH
+
+ const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
+ Math.round((max / Y_TICK_COUNT) * i),
+ )
+
+ const firstSeries = series[0]
+ const xSamples =
+ firstSeries && firstSeries.data.length > 0
+ ? [
+ firstSeries.data[0].x,
+ firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
+ firstSeries.data[firstSeries.data.length - 1].x,
+ ].filter(Boolean)
+ : []
+
+ function handleMouseMove(e: React.MouseEvent) {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const mx = e.clientX - rect.left
+ const my = e.clientY - rect.top
+ const pctX = (mx - dims.paddingLeft) / plotW
+
+ const values = series.map((s, i) => {
+ const idx = Math.round(pctX * (s.data.length - 1))
+ const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
+ const pt = s.data[clamped]
+ return {
+ label: s.label,
+ value: pt?.y ?? 0,
+ color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
+ }
+ })
+
+ setTooltip({ x: mx, y: my, values })
+ }
+
+ return (
+
+ {yLabel &&
{yLabel}
}
+
+
+ {/* Tooltip */}
+ {tooltip && (
+
+ {tooltip.values.map((v) => (
+
+
+ {v.label}:
+ {formatAxisLabel(v.value)}
+
+ ))}
+
+ )}
+
+ {/* Legend */}
+ {series.length > 1 && (
+
+ {series.map((s, i) => (
+
+
+ {s.label}
+
+ ))}
+
+ )}
+
+ {xLabel &&
{xLabel}
}
+
+ )
+}
diff --git a/src/design-system/composites/_chart-utils.ts b/src/design-system/composites/_chart-utils.ts
new file mode 100644
index 0000000..8add68d
--- /dev/null
+++ b/src/design-system/composites/_chart-utils.ts
@@ -0,0 +1,101 @@
+export interface DataPoint {
+ x: number | Date
+ y: number
+}
+
+export interface ChartSeries {
+ label: string
+ data: DataPoint[]
+ color?: string
+}
+
+export interface ChartDimensions {
+ width: number
+ height: number
+ paddingTop: number
+ paddingRight: number
+ paddingBottom: number
+ paddingLeft: number
+}
+
+export function computeYScale(
+ series: ChartSeries[],
+ dims: ChartDimensions,
+ threshold?: number,
+) {
+ const allY = series.flatMap((s) => s.data.map((d) => d.y))
+ if (threshold != null) allY.push(threshold)
+ const min = 0
+ const max = Math.max(...allY, 1)
+ const range = max - min
+
+ const plotH = dims.height - dims.paddingTop - dims.paddingBottom
+ const toY = (val: number) => dims.paddingTop + plotH - ((val - min) / range) * plotH
+
+ return { min, max, range, toY }
+}
+
+export function computeXScale(series: ChartSeries[], dims: ChartDimensions) {
+ const allX = series.flatMap((s) =>
+ s.data.map((d) => (d.x instanceof Date ? d.x.getTime() : d.x)),
+ )
+ const minX = Math.min(...allX)
+ const maxX = Math.max(...allX)
+ const rangeX = maxX - minX || 1
+
+ const plotW = dims.width - dims.paddingLeft - dims.paddingRight
+ const toX = (val: number | Date) => {
+ const v = val instanceof Date ? val.getTime() : val
+ return dims.paddingLeft + ((v - minX) / rangeX) * plotW
+ }
+
+ return { minX, maxX, rangeX, toX }
+}
+
+export function seriesPoints(
+ series: ChartSeries,
+ toX: (v: number | Date) => number,
+ toY: (v: number) => number,
+): string {
+ return series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`).join(' ')
+}
+
+export function seriesPath(
+ series: ChartSeries,
+ toX: (v: number | Date) => number,
+ toY: (v: number) => number,
+ bottomY: number,
+): string {
+ if (series.data.length === 0) return ''
+ const pts = series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`)
+ const firstX = toX(series.data[0].x).toFixed(1)
+ const lastX = toX(series.data[series.data.length - 1].x).toFixed(1)
+ return `M${pts.join(' L')} L${lastX},${bottomY} L${firstX},${bottomY} Z`
+}
+
+export function formatAxisLabel(val: number): string {
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
+ if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
+ if (Number.isInteger(val)) return String(val)
+ return val.toFixed(1)
+}
+
+export function formatXLabel(val: number | Date, totalPoints: number): string {
+ if (val instanceof Date || (typeof val === 'number' && val > 1e10)) {
+ const d = val instanceof Date ? val : new Date(val)
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ }
+ return formatAxisLabel(val as number)
+}
+
+// Default color tokens for series (fallback when no color given)
+export const CHART_COLORS = [
+ 'var(--chart-1)',
+ 'var(--chart-2)',
+ 'var(--chart-3)',
+ 'var(--chart-4)',
+ 'var(--chart-5)',
+ 'var(--chart-6)',
+ 'var(--chart-7)',
+ 'var(--chart-8)',
+]