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) => ( + + + ))} +
+ )} + + + {Array.from({ length: gridLines }, (_, i) => { + const y = round(PLOT_PADDING + (plotHeight * (i + 1)) / (gridLines + 1)); + return ; + })} + + + {drawable.map((s) => ( + + ))} + + {xLabels != null && xLabels.length > 0 && ( + + {xLabels.map((label, index) => { + const x = + xLabels.length > 1 + ? (width * index) / (xLabels.length - 1) + : width / 2; + const anchor = tickAnchor(index, xLabels.length); + return ( + + {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 ( + + {area && ( + + )} + + + ); +} 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; +}