diff --git a/apps/frontend/src/renderer/__tests__/ui-charts.test.ts b/apps/frontend/src/renderer/__tests__/ui-charts.test.ts
new file mode 100644
index 000000000..723c7fe8a
--- /dev/null
+++ b/apps/frontend/src/renderer/__tests__/ui-charts.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Tests for the @auto-code/ui chart-geometry helpers (Sparkline / LineChart).
+ * libs/ui has no CI test runner, so the pure math is exercised here.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { pointsToArea, seriesToPoints } from '@auto-code/ui';
+
+describe('seriesToPoints', () => {
+ it('spreads points evenly across the width and inverts Y', () => {
+ const parsed = seriesToPoints([0, 1, 2], 200, 100, 0)
+ .split(' ')
+ .map((p) => p.split(',').map(Number));
+ expect(parsed.map(([x]) => x)).toEqual([0, 100, 200]);
+ // min sits at the bottom, max at the top (SVG origin is top-left).
+ expect(parsed[0][1]).toBe(100);
+ expect(parsed[2][1]).toBe(0);
+ expect(parsed[1][1]).toBe(50);
+ });
+
+ it('centers a flat series vertically', () => {
+ for (const p of seriesToPoints([5, 5, 5], 100, 40, 0).split(' ')) {
+ expect(Number(p.split(',')[1])).toBe(20);
+ }
+ });
+
+ it('applies vertical padding so the line never touches the edges', () => {
+ const ys = seriesToPoints([0, 10], 100, 100, 10)
+ .split(' ')
+ .map((p) => Number(p.split(',')[1]));
+ expect(Math.max(...ys)).toBe(90);
+ expect(Math.min(...ys)).toBe(10);
+ });
+
+ it('returns an empty string for an empty series', () => {
+ expect(seriesToPoints([], 100, 40)).toBe('');
+ });
+
+ it('places a single point at the horizontal center', () => {
+ expect(seriesToPoints([7], 100, 40, 0)).toBe('50,20');
+ });
+});
+
+describe('pointsToArea', () => {
+ it('closes the line down to the baseline corners', () => {
+ expect(pointsToArea('0,10 100,0', 100, 40)).toBe('0,10 100,0 100,40 0,40');
+ });
+
+ it('returns an empty string when there are no points', () => {
+ expect(pointsToArea('', 100, 40)).toBe('');
+ });
+});
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index 154c7c761..526ec2d31 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -7,6 +7,17 @@ export { Input } from './primitives/Input';
export type { InputProps } from './primitives/Input';
export { Kbd } from './primitives/Kbd';
export type { KbdProps } from './primitives/Kbd';
+export { Sparkline } from './primitives/Sparkline';
+export type { SparklineProps } from './primitives/Sparkline';
+export { LineChart } from './primitives/LineChart';
+export type {
+ LineChartProps,
+ LineChartSeries,
+} from './primitives/LineChart';
+export { StatTile } from './primitives/StatTile';
+export type { StatTileProps, StatTileTone } from './primitives/StatTile';
+export { seriesToPoints, pointsToArea, CHART_TONE_VARS } from './primitives/charts';
+export type { ChartTone } from './primitives/charts';
export { ThemeProvider, useTheme } from './theme/ThemeProvider';
export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider';
export { AppShell } from './shell/AppShell';
diff --git a/libs/ui/src/primitives/LineChart.css b/libs/ui/src/primitives/LineChart.css
new file mode 100644
index 000000000..9417ebce3
--- /dev/null
+++ b/libs/ui/src/primitives/LineChart.css
@@ -0,0 +1,28 @@
+.ac-linechart {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+}
+.ac-linechart__svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+.ac-linechart__legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ font-size: 11.5px;
+ color: var(--muted);
+}
+.ac-linechart__legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.ac-linechart__swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+}
diff --git a/libs/ui/src/primitives/LineChart.stories.tsx b/libs/ui/src/primitives/LineChart.stories.tsx
new file mode 100644
index 000000000..6cbf174a7
--- /dev/null
+++ b/libs/ui/src/primitives/LineChart.stories.tsx
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { LineChart } from './LineChart';
+
+const meta = {
+ title: 'Primitives/LineChart',
+ component: LineChart,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ series: [
+ {
+ label: 'Merged',
+ tone: 'good',
+ values: [4, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16],
+ },
+ {
+ label: 'Opened',
+ tone: 'info',
+ values: [8, 9, 7, 11, 10, 12, 11, 13, 12, 14, 13, 15],
+ },
+ ],
+ xLabels: ['Apr 1', 'Apr 8', 'Apr 15', 'Apr 22', 'May 1', 'May 8'],
+ gridLines: 4,
+ showLegend: true,
+ ariaLabel: 'Merged vs opened PRs over six weeks',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const TwoSeries: Story = {};
+
+export const SingleSeries: Story = {
+ args: {
+ series: [
+ { label: 'Throughput', tone: 'info', values: [12, 14, 11, 18, 16, 22, 20, 26] },
+ ],
+ showLegend: false,
+ },
+};
+
+export const NoAxisLabels: Story = {
+ args: { xLabels: undefined },
+};
diff --git a/libs/ui/src/primitives/LineChart.tsx b/libs/ui/src/primitives/LineChart.tsx
new file mode 100644
index 000000000..2ef7c64b0
--- /dev/null
+++ b/libs/ui/src/primitives/LineChart.tsx
@@ -0,0 +1,155 @@
+import { CHART_TONE_VARS, type ChartTone } from './charts';
+import './LineChart.css';
+
+export interface LineChartSeries {
+ label: string;
+ values: readonly number[];
+ tone?: ChartTone;
+}
+
+export interface LineChartProps {
+ /** One or more series, plotted on a shared y-scale. */
+ series: readonly LineChartSeries[];
+ /** Optional x-axis tick labels (rendered evenly across the width). */
+ xLabels?: readonly string[];
+ /** Number of horizontal grid lines. */
+ gridLines?: number;
+ /** Show a legend row above the chart. */
+ showLegend?: boolean;
+ /** Accessible label; when omitted the chart svg is decorative (aria-hidden). */
+ ariaLabel?: string;
+ /** viewBox width. */
+ width?: number;
+ /** viewBox height (chart plot area, excluding the axis-label row). */
+ height?: number;
+ className?: string;
+}
+
+const PLOT_PADDING = 8;
+const AXIS_ROW = 18;
+
+/**
+ * Multi-series line chart drawn as SVG polylines over a dashed grid, mirroring
+ * the `.lazyweb` line-chart. All series share one y-scale so they compare
+ * directly. Presentational and data-agnostic.
+ */
+export function LineChart({
+ series,
+ xLabels,
+ gridLines = 4,
+ showLegend = false,
+ ariaLabel,
+ width = 760,
+ height = 220,
+ className,
+}: Readonly) {
+ const drawable = series.filter((s) => s.values.length >= 2);
+ const allValues = drawable.flatMap((s) => s.values);
+ const min = allValues.length > 0 ? Math.min(...allValues) : 0;
+ const max = allValues.length > 0 ? Math.max(...allValues) : 1;
+ const span = max - min;
+ const plotHeight = height - PLOT_PADDING * 2;
+
+ const toPoints = (values: readonly number[]): string => {
+ const stepX = values.length > 1 ? width / (values.length - 1) : 0;
+ return values
+ .map((value, index) => {
+ const x = index * stepX;
+ const ratio = span === 0 ? 0.5 : (value - min) / span;
+ const y = PLOT_PADDING + (1 - ratio) * plotHeight;
+ return `${round(x)},${round(y)}`;
+ })
+ .join(' ');
+ };
+
+ const totalHeight = xLabels != null ? height + AXIS_ROW : height;
+ const labelled = ariaLabel != null;
+
+ return (
+
+ {showLegend && (
+
+ {series.map((s) => (
+
+
+ {s.label}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function round(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
+/** Edge ticks anchor to the plot edges so labels don't overflow the viewBox. */
+function tickAnchor(index: number, count: number): 'start' | 'middle' | 'end' {
+ if (index === 0) return 'start';
+ if (index === count - 1) return 'end';
+ return 'middle';
+}
diff --git a/libs/ui/src/primitives/Sparkline.css b/libs/ui/src/primitives/Sparkline.css
new file mode 100644
index 000000000..d55ffa3f8
--- /dev/null
+++ b/libs/ui/src/primitives/Sparkline.css
@@ -0,0 +1,6 @@
+.ac-sparkline {
+ display: block;
+ width: 100%;
+ height: 36px;
+ overflow: visible;
+}
diff --git a/libs/ui/src/primitives/Sparkline.stories.tsx b/libs/ui/src/primitives/Sparkline.stories.tsx
new file mode 100644
index 000000000..4bca5cd13
--- /dev/null
+++ b/libs/ui/src/primitives/Sparkline.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Sparkline } from './Sparkline';
+
+const meta = {
+ title: 'Primitives/Sparkline',
+ component: Sparkline,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ values: [28, 26, 22, 24, 18, 20, 14, 16, 12, 10, 8, 6, 4],
+ tone: 'info',
+ ariaLabel: 'Throughput trending up over 13 days',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Rising: Story = {};
+
+export const WithArea: Story = {
+ args: { area: true },
+};
+
+export const Falling: Story = {
+ args: {
+ values: [8, 12, 10, 14, 16, 18, 20, 18, 22, 24, 22, 26, 28],
+ tone: 'warn',
+ ariaLabel: 'Latency trending up over 13 days',
+ },
+};
+
+export const Flat: Story = {
+ args: { values: [10, 10, 10, 10, 10], tone: 'neutral' },
+};
+
+export const Good: Story = {
+ args: {
+ values: [4, 6, 5, 8, 7, 10, 9, 12, 11, 14],
+ tone: 'good',
+ area: true,
+ },
+};
diff --git a/libs/ui/src/primitives/Sparkline.tsx b/libs/ui/src/primitives/Sparkline.tsx
new file mode 100644
index 000000000..c324d82d5
--- /dev/null
+++ b/libs/ui/src/primitives/Sparkline.tsx
@@ -0,0 +1,74 @@
+import {
+ CHART_TONE_VARS,
+ pointsToArea,
+ seriesToPoints,
+ type ChartTone,
+} from './charts';
+import './Sparkline.css';
+
+export interface SparklineProps {
+ /** The numeric series to plot. Fewer than two points renders nothing. */
+ values: readonly number[];
+ /** Line color tone; defaults to info (blue). */
+ tone?: ChartTone;
+ /** Draw a soft area fill under the line. */
+ area?: boolean;
+ /**
+ * Accessible label describing the trend. When omitted the sparkline is
+ * treated as decorative (aria-hidden) — pair it with a labeled value.
+ */
+ ariaLabel?: string;
+ /** viewBox width; the SVG scales to its container. */
+ width?: number;
+ /** viewBox height. */
+ height?: number;
+ className?: string;
+}
+
+/**
+ * Compact inline trend line drawn as a single SVG polyline (optionally with a
+ * soft area fill), mirroring the `.lazyweb` sparkline. Presentational and
+ * data-agnostic; the container controls the rendered size.
+ */
+export function Sparkline({
+ values,
+ tone = 'info',
+ area = false,
+ ariaLabel,
+ width = 240,
+ height = 36,
+ className,
+}: Readonly) {
+ if (values.length < 2) return null;
+ const color = CHART_TONE_VARS[tone];
+ const points = seriesToPoints(values, width, height);
+ const labelled = ariaLabel != null;
+
+ return (
+
+ );
+}
diff --git a/libs/ui/src/primitives/StatTile.css b/libs/ui/src/primitives/StatTile.css
new file mode 100644
index 000000000..26133bbbc
--- /dev/null
+++ b/libs/ui/src/primitives/StatTile.css
@@ -0,0 +1,36 @@
+.ac-stat-tile {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 12px 14px;
+}
+.ac-stat-tile__value,
+.ac-stat-tile__value--good,
+.ac-stat-tile__value--warn,
+.ac-stat-tile__value--bad {
+ font-size: 20px;
+ font-weight: 800;
+}
+.ac-stat-tile__value--good {
+ color: var(--green);
+}
+.ac-stat-tile__value--warn {
+ color: var(--amber);
+}
+.ac-stat-tile__value--bad {
+ color: var(--red);
+}
+.ac-stat-tile__label {
+ font-size: 11.5px;
+ color: var(--muted);
+}
+.ac-stat-tile__sub {
+ font-size: 10.5px;
+ color: var(--quiet);
+}
+.ac-stat-tile__spark {
+ margin-top: 8px;
+}
diff --git a/libs/ui/src/primitives/StatTile.stories.tsx b/libs/ui/src/primitives/StatTile.stories.tsx
new file mode 100644
index 000000000..e18ab8dd8
--- /dev/null
+++ b/libs/ui/src/primitives/StatTile.stories.tsx
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { StatTile } from './StatTile';
+import { Sparkline } from './Sparkline';
+
+const meta = {
+ title: 'Primitives/StatTile',
+ component: StatTile,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ value: '112',
+ label: 'specs shipped',
+ sub: 'this quarter',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Plain: Story = {};
+
+export const Toned: Story = {
+ args: { value: '87%', label: 'QA pass rate', sub: 'last 30 days', tone: 'good' },
+};
+
+export const WithSparkline: Story = {
+ args: {
+ value: '4.2k',
+ label: 'tokens / spec',
+ sub: 'trending down',
+ tone: 'warn',
+ sparkline: (
+
+ ),
+ },
+};
diff --git a/libs/ui/src/primitives/StatTile.tsx b/libs/ui/src/primitives/StatTile.tsx
new file mode 100644
index 000000000..9b02c36e7
--- /dev/null
+++ b/libs/ui/src/primitives/StatTile.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react';
+import './StatTile.css';
+
+export type StatTileTone = 'good' | 'warn' | 'bad';
+
+export interface StatTileProps {
+ /** The primary figure (pre-formatted, e.g. "112k", "87%"). */
+ value: string;
+ /** Short label under the value. */
+ label: string;
+ /** Optional secondary sub-line. */
+ sub?: string;
+ /** Colors the value; default is the neutral ink color. */
+ tone?: StatTileTone;
+ /** Optional trend visual (typically a ) rendered below. */
+ sparkline?: ReactNode;
+ className?: string;
+}
+
+/**
+ * A single dashboard summary tile: big value + label + optional sub-line and
+ * an optional trend visual. Mirrors the `.lazyweb` analytics stat tile.
+ * Presentational — the composing screen supplies formatted strings.
+ */
+export function StatTile({
+ value,
+ label,
+ sub,
+ tone,
+ sparkline,
+ className,
+}: Readonly) {
+ return (
+
+
+ {value}
+
+
{label}
+ {sub != null &&
{sub}}
+ {sparkline != null && (
+
{sparkline}
+ )}
+
+ );
+}
diff --git a/libs/ui/src/primitives/charts.ts b/libs/ui/src/primitives/charts.ts
new file mode 100644
index 000000000..62fc78de4
--- /dev/null
+++ b/libs/ui/src/primitives/charts.ts
@@ -0,0 +1,52 @@
+/** Shared chart tones → CSS token color, used by Sparkline and LineChart. */
+export type ChartTone = 'info' | 'good' | 'warn' | 'bad' | 'neutral';
+
+export const CHART_TONE_VARS: Record = {
+ info: 'var(--blue)',
+ good: 'var(--green)',
+ warn: 'var(--amber)',
+ bad: 'var(--red)',
+ neutral: 'var(--quiet)',
+};
+
+/**
+ * Map a numeric series to evenly-spaced "x,y" SVG points, normalized so the
+ * min value sits near the bottom and the max near the top of `height` (with a
+ * small vertical padding). A flat series is drawn as a centered line.
+ */
+export function seriesToPoints(
+ values: readonly number[],
+ width: number,
+ height: number,
+ padding = 3,
+): string {
+ if (values.length === 0) return '';
+ const usable = Math.max(1, height - padding * 2);
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min;
+ const stepX = values.length > 1 ? width / (values.length - 1) : 0;
+ return values
+ .map((value, index) => {
+ const x = values.length > 1 ? index * stepX : width / 2;
+ // Invert Y (SVG origin is top-left); flat series centers vertically.
+ const ratio = span === 0 ? 0.5 : (value - min) / span;
+ const y = padding + (1 - ratio) * usable;
+ return `${round(x)},${round(y)}`;
+ })
+ .join(' ');
+}
+
+/** Close a line's points into an area polygon down to the baseline. */
+export function pointsToArea(
+ points: string,
+ width: number,
+ height: number,
+): string {
+ if (points === '') return '';
+ return `${points} ${round(width)},${round(height)} 0,${round(height)}`;
+}
+
+function round(n: number): number {
+ return Math.round(n * 100) / 100;
+}