diff --git a/docs/components.md b/docs/components.md
index 375455d..ca6dc19 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -1,223 +1,60 @@
-# Shared Component Usage
+# Components
-Short usage docs for the components under `src/components/` that other
-contributors are most likely to reuse. Keep these in sync with the actual props
-if they change.
+This document describes the shared UI components used across the application.
-## `IntentStatusBadge`
+## Charts (`src/components/charts/*`)
-[`src/components/IntentStatusBadge.tsx`](../src/components/IntentStatusBadge.tsx)
+A small, dependency-free toolkit of accessible SVG chart primitives. All
+components are typed generics, use Tailwind palette tokens (light + dark), and
+share consistent scales, tooltips, legends, responsive sizing, reduced-motion
+support, and accessible alternatives.
-Renders a pill badge for an intent's lifecycle status, with a distinct icon per
-status (not just color) so it doesn't rely on color alone to differentiate.
+### Available components
-```tsx
-import { IntentStatusBadge } from "@/components/IntentStatusBadge";
-
-;
-```
-
-**Props**
-
-| prop | type | required | notes |
-| -------- | -------------- | -------- | ------------------------------------------------------------------------------ |
-| `status` | `IntentStatus` | yes | one of `"pending" \| "accepted" \| "filled" \| "failed"` (see `src/lib/types`) |
-
-No other configuration — styling and icon are derived entirely from `status` via
-the internal `STATUS_STYLES` / `STATUS_ICONS` maps. To support a new status,
-add an entry to both maps and to the `IntentStatus` type.
-
-## `ToastViewport`
-
-[`src/components/ToastViewport.tsx`](../src/components/ToastViewport.tsx)
-
-Fixed-position (bottom-right) container that renders the current toast queue
-from [`useToastStore`](../src/store/toast.ts) and handles dismissal. See
-[`docs/toast-system.md`](./toast-system.md) for the full API on how to push
-toasts.
-
-```tsx
-import { ToastViewport } from "@/components/ToastViewport";
-
-;
-```
-
-**Props**: none. Mount it once, near the root of the tree — it's already mounted
-in [`src/app/layout.tsx`](../src/app/layout.tsx), so you should not need to mount
-it again in a page or feature component. It renders `null` when there are no
-active toasts.
-
-## `ConnectWalletButton`
-
-[`src/components/ConnectWalletButton.tsx`](../src/components/ConnectWalletButton.tsx)
-
-Self-contained wallet connect/disconnect button. Reads and drives
-[`useWalletStore`](../src/store/wallet.ts) directly — no props are needed to wire
-it up to wallet state.
-
-```tsx
-import { ConnectWalletButton } from "@/components/ConnectWalletButton";
-
-
-
-```
-
-**Props**
-
-| prop | type | required | default | notes |
-| --------- | --------- | -------- | ------- | ---------------------------------------------------------------------- |
-| `compact` | `boolean` | no | `false` | tighter padding/layout for constrained spaces (e.g. mobile nav/header) |
-
-**Behavior**
-
-- Not connected: shows "Connect Freighter" (or "Retry Connection" if a previous
- attempt errored, with the error message as the `title` tooltip). Disabled with
- a "Connecting..." label while `isConnecting`.
-- Connected: shows the truncated address, swapping to "Disconnect" on hover/focus.
-- On a failed `connect()` call, it also pushes an error toast via
- [`useToastStore`](../src/store/toast.ts) — you don't need to handle connection
- errors yourself when using this component.
-
-## `Tooltip`
-
-[`src/components/Tooltip.tsx`](../src/components/Tooltip.tsx)
-
-Accessible WAI-ARIA tooltip component. Shown on hover and keyboard focus
-(never mouse-only), dismissed via Escape, and associated to its trigger via
-`aria-describedby`. Includes basic viewport-edge collision handling and a
-tap-to-toggle affordance for touch devices.
-
-```tsx
-import { Tooltip } from "@/components/Tooltip";
-
-
- Protocol fee
-
-```
-
-**Props**
-
-| prop | type | required | default | notes |
-| ----------- | ----------- | -------- | -------- | ------------------------------------------------------------------------------------ |
-| `content` | `ReactNode` | yes | — | The tooltip text or element shown in the popover. |
-| `children` | `ReactElement` | yes | — | Single trigger element. Must accept `ref`, `aria-describedby`, focus/blur/mouse handlers. |
-| `placement` | `"top" \| "bottom"` | no | `"top"` | Preferred placement; auto-flips when close to viewport edge. |
-
-**Behaviour**
-
-- Hover or keyboard focus opens the tooltip; losing either closes it.
-- Pressing Escape dismisses the tooltip from anywhere on the page.
-- Touch: tap the trigger to toggle the tooltip open/closed.
-- The trigger receives `aria-describedby` pointing to the tooltip while it is visible.
-- Does not trap focus or interfere with Tab order.
-- Currently applied to `Price impact`, `Protocol fee`, and `Est. fill time` in
- SwapCard's quote details panel. See `Tooltip.stories.tsx` for interactive examples.
-
-
-## Live feed buffering (`useBufferedFeed`, `LiveFeedControls`)
-
-Home `ActivityFeed`, Explore and My Intents stop inserting rows while the user is reading (WCAG 2.2.2).
-`useFeedPause(feedId)` pauses when the pointer or focus is inside the list, when it is scrolled away from the
-top, or when the user presses the Live/Paused toggle (persisted per feed under `vortex-feed-paused:`).
-`useBufferedFeed(items, { isPaused })` returns `{ visible, pending, overflow, flush }`: already-visible items
-keep updating in place (status changes) without reordering, new ones are queued (count capped at 500, shown
-as "500+"). The "N new intents" pill flushes the queue and moves focus to the list; counts are announced
-politely at most every 5 s. See the `BufferedLiveUpdates` story.
-
-## `DataTable`
-
-Generic accessible table (`src/components/DataTable.tsx`), first used by the solver leaderboard.
-
-- Real `
` with a screen-reader caption, `scope`d headers, a row header per row and `aria-sort` on the primary sorted column.
-- Sort buttons in headers: click = single sort (asc → desc → cleared), **shift-click / Shift+Enter / Shift+Space** = add a secondary key. Sort state is owned by the caller (`sorts` / `onSort`), so it can live in the URL.
-- Sticky header; below 640 px each row collapses into a labelled card via CSS (`data-label`), keeping a single DOM.
-- More than `virtualizeAbove` (default 200) rows are windowed with `@tanstack/react-virtual`.
-- Story: `DataTable.stories.tsx` (ties, zero fills, spoofing characters, 500 rows, mobile).
-
-## Solver portal (`/solve`)
-
-`SolvePageClient.tsx` went from 708 lines to about 125 and is now just the page shell: hero, steps, and the tab
-list. Each tab is its own module in `src/app/solve/_components/`:
-
-| Module | Responsibility |
+| Component | Description |
| --- | --- |
-| `LeaderboardTab` | `useSolvers`, sort state, renders `SolverRow` |
-| `OpenIntentsTab` | `useOpenIntents` + `useAcceptIntent`, renders `DeadlineChip` |
-| `RegisterSolverTab` | `useRegistrationForm` + `useSolverRegistration`, renders `SolverOnboardingChecklist` |
-| `useRegistrationForm` | reducer owning field state, validation and per-wallet draft persistence |
-| `useSolveTab` | active tab synced to `?tab=` |
-
-Data fetching stays in hooks called by each tab; `SolverRow`, `DeadlineChip` and the checklist are
-presentational. Formatting helpers (`usdCompact`, `formatTimeRemaining`) live in `src/lib/format.ts`.
-
-**Tabs** follow the WAI-ARIA tabs pattern: roving `tabIndex`, Arrow Left/Right wrap, and Home/End jump to the
-first and last tab. The URL is read after mount, so server and client render the same markup, and it is
-updated with `history.replaceState`, which adds no history entries or route transitions. **Panels are kept
-alive:** a tab mounts the first time it is shown and is then hidden rather than unmounted. That keeps each
-tab's scroll position, sort order and form input, at the cost of keeping its SWR subscriptions running.
-
-All portal state is URL-synced (`useQueryState`) so views are shareable and the back button works.
-
-- **`SolverLeaderboard`** — ranking from `rankSolvers()` (`src/lib/solverRanking.ts`): volume → fills → success rate → avg fill time → address (stable tiebreak); success rate is recomputed from fills/failed and is 0 for solvers without attempts. Time windows `24h | 7d | 30d | all` read `GET /solvers?window=…` (the relay aggregates per window). Rank deltas use the relay's `previousRank` when present, otherwise a snapshot persisted in `localStorage` per window. Filters: chain, status, min bond, verified-only. Column visibility via `useColumnVisibility`. CSV export of the visible rows/columns through the injection-safe `buildCsv`. URL keys: `window`, `sort` (`key:dir,…`), `chain`, `status`, `minBond`, `verified`.
-- **`OpenIntentsBoard`** — `useOpenIntentBoard` merges the `/intents/open` REST snapshot with `intent.open` / `intent.closed` WebSocket events (see `websocket-protocol.md`). Sorted by soonest deadline; countdowns use `formatTimeRemaining` and turn urgent under 60 s; Accept is disabled once expired, on a network mismatch, or when the connected wallet is not a registered solver. Accept outcomes drive a per-row state machine (`rowReducer`): 409 → "Taken by another solver" (announced, removed after 3 s), 410/expired → explanatory state, success → "Accepted by you". While the pointer or focus is inside the list, updates are buffered (no jumping rows) and offered via a "show N updates" button. URL keys: `ichain`, `itoken`, `minUsd`, `density`.
- - *Performance (200 rows):* one shared 1 s ticker (`useNow`, `useSyncExternalStore`) drives every countdown instead of a timer per row, filtering/sorting is memoised, and a tick only changes countdown text, so 200 rows cost one interval and one list re-render per second (no per-row effects). If profiling shows otherwise at larger sizes, the list can adopt the same virtualisation as `DataTable`.
-- **`RegistrationWizard`** — steps *eligibility → verify address → bond → review & sign → done* driven by the pure `wizardReducer` with per-step validators (`src/lib/registrationWizard.ts`). Eligibility checks (wallet, network, valid address, not already registered, account funded via the `/api/account-status` Horizon proxy — cancelable) each show pass/fail/pending plus remediation text. Bond maths uses 7-decimal integer (BigInt) units; minimum, suggestions and the unbonding period come from `SOLVER_BOND_CONFIG`. Progress is saved per wallet with a 24 h TTL (`useLocalStorageDraft`) and restored only through an explicit "Resume registration" banner; switching wallets mid-flow resets with a notice; a draft whose bond is now below the minimum is sent back to the bond step. The step is mirrored in `?step=` so the browser back button moves between steps.
-- **`SolverBadge` / `SolverIdentityChip`** — optional stellar.toml identity chip (`verified | unverified | mismatch | unavailable`) with icon + text + tooltip (never colour-only). Display only — see the threat model in `security-audit.md`.
-
-## `IntentTracker`
-
-[`src/components/IntentTracker.tsx`](../src/components/IntentTracker.tsx) shows an
-intent's journey after submission: **submitted → accepted → filled / failed /
-expired**, with step timestamps, a live deadline countdown and localised
-next-step guidance for each state.
-
-- **Where:** under the swap card on `/` after a submit (the intent id is
- persisted in `localStorage` under `vortex:lastSubmittedIntent`, so a reload
- mid-flight keeps tracking; terminal trackers can be dismissed) and on
- `/explore/[id]`.
-- **Data:** `useIntentLifecycle(id)` merges the REST detail (`/intents/:id`)
- with WebSocket updates from the shared realtime connection, filtered by id.
- Status never regresses on out-of-order frames. When the socket is not open
- it polls with backoff (5 s doubling to 60 s) until the intent is terminal.
-- **States:** `expired` is derived client-side (non-terminal past its
- deadline) and is distinct from `failed`. The API exposes only `createdAt`,
- so accept/fill timestamps are the times the client observed them and are
- omitted when unknown. Derivation is the pure `deriveTrackerSteps(intent, now)`
- in [`src/lib/intentLifecycle.ts`](../src/lib/intentLifecycle.ts).
-- **Retry:** failed/expired intents offer "Retry this swap", which links to
- `/?srcChain=&srcToken=&amount=&dstToken=` to pre-fill `SwapCard`. The
- destination address is never carried over (existing policy).
-- **Cancel — known gap:** neither `src/lib/api.ts` nor the relay contract
- exposes a cancel endpoint today, so no Cancel action is shown. When one
- lands, add it behind a feature flag with a confirmation dialog and the
- standard XDR review step. Refund execution is out of scope.
-
-## `FeedbackForm`
-
-[`src/components/FeedbackForm.tsx`](../src/components/FeedbackForm.tsx)
-
-"Suggest a feature" intake for users who don't file GitHub issues. A toggle in
-the footer opens a small form (title + description); submitting opens GitHub's
-new-issue page in a new tab, pre-filled by
-[`buildFeatureRequestUrl`](../src/lib/featureRequest.ts) with the `enhancement`
-label and a body that follows
-[`feature_request.md`](../.github/ISSUE_TEMPLATE/feature_request.md)'s sections.
+| `LineChart` | Line series with focusable, arrow-key navigable data points. |
+| `AreaChart` | Filled area series built on the same scale helpers as `LineChart`. |
+| `BarChart` | Band-scaled bars with pattern/marker supplements to colour. |
+| `DonutChart` | Categorical donut with a legend and per-slice markers. |
+| `Sparkline` | Compact inline trend line for dense layouts. |
+| `Legend` | Shared legend that pairs each series with a colour **and** a pattern/marker. |
+| `ChartTooltip` | Tooltip surface announced through an `aria-live` region. |
+
+### Accessibility
+
+- **Keyboard:** every data point is focusable and navigable with the arrow
+ keys. The active point's tooltip content is announced through an `aria-live`
+ region.
+- **Data-table fallback:** each chart exposes a "View as table" toggle that
+ renders the underlying data as an accessible `
`.
+- **Text summary:** each chart renders a short text summary referenced by
+ `aria-describedby`.
+- **Non-colour encodings:** patterns and markers supplement colour so states
+ are distinguishable without relying on colour alone (WCAG 1.4.1). Colours
+ meet 3:1 contrast against both the light and dark palettes.
+
+### Responsiveness and data handling
+
+- Charts measure their container with `ResizeObserver` and are SSR-safe.
+- Empty, single-point, and very large series (up to 10k points) are supported;
+ large series are downsampled with the LTTB algorithm.
+- Scale helpers (linear, time, band) are pure functions, separated from
+ rendering for testability.
+
+### Usage
```tsx
-import { FeedbackForm } from "@/components/FeedbackForm";
-
-;
+import { LineChart, Legend } from "@/components/charts";
+
+ d.date}
+ yAccessor={(d) => d.value}
+ ariaLabel="Volume over time"
+/>
```
-**Props**: none. Already mounted in [`Footer`](../src/components/Footer.tsx).
-
-**Behaviour**
-
-- No backend: nothing is stored in-app, and posting the issue needs a GitHub
- account. The form says so before the user submits.
-- Titles are capped at 120 characters and descriptions at 2,000 (longer text is
- truncated with a note in the issue body), which keeps the URL within the
- length browsers and GitHub reliably accept.
-- The `template` query parameter isn't used, because GitHub would then show the
- template's empty body instead of the pre-filled one.
-
+See `src/lib/analytics.ts` (`getStatusChartColors`) for the shared status
+palette, which pairs each state with a distinct pattern/marker in addition to
+its colour.
diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts
new file mode 100644
index 0000000..91d47ff
--- /dev/null
+++ b/src/components/charts/index.ts
@@ -0,0 +1,321 @@
+'use client';
+
+import { useCallback, useId, useMemo, useRef, useState, type ReactNode } from 'react';
+
+export type ChartDatum = { label: string; value: number };
+
+export type ChartSeries = { name: string; data: ChartDatum[] };
+
+export type ChartProps = {
+ series: ChartSeries[];
+ width?: number;
+ height?: number;
+ title?: string;
+ summary?: string;
+ className?: string;
+};
+
+const PALETTE = ['#2563eb', '#16a34a', '#d97706', '#dc2626', '#7c3aed', '#0891b2'];
+const MARKERS = ['circle', 'square', 'triangle', 'diamond', 'cross', 'star'] as const;
+
+export function chartColor(index: number): string {
+ return PALETTE[index % PALETTE.length];
+}
+
+export function chartMarker(index: number): string {
+ return MARKERS[index % MARKERS.length];
+}
+
+function filterFinite(data: ChartDatum[]): ChartDatum[] {
+ return data.filter((d) => Number.isFinite(d.value));
+}
+
+export function linearScale(domain: [number, number], range: [number, number]) {
+ const [d0, d1] = domain;
+ const [r0, r1] = range;
+ const span = d1 - d0 || 1;
+ return (value: number) => r0 + ((value - d0) / span) * (r1 - r0);
+}
+
+export function bandScale(count: number, range: [number, number]) {
+ const [r0, r1] = range;
+ const step = count > 0 ? (r1 - r0) / count : 0;
+ return (index: number) => r0 + step * index + step / 2;
+}
+
+export function lttb(data: ChartDatum[], threshold: number): ChartDatum[] {
+ if (threshold >= data.length || threshold < 3) return data;
+ const sampled: ChartDatum[] = [data[0]];
+ const every = (data.length - 2) / (threshold - 2);
+ let a = 0;
+ for (let i = 0; i < threshold - 2; i++) {
+ const rangeStart = Math.floor((i + 1) * every) + 1;
+ const rangeEnd = Math.min(Math.floor((i + 2) * every) + 1, data.length);
+ let avgX = 0;
+ let avgY = 0;
+ for (let j = rangeStart; j < rangeEnd; j++) {
+ avgX += j;
+ avgY += data[j].value;
+ }
+ const n = rangeEnd - rangeStart || 1;
+ avgX /= n;
+ avgY /= n;
+ const rangeOffs = Math.floor(i * every) + 1;
+ const rangeTo = Math.floor((i + 1) * every) + 1;
+ const pointAX = a;
+ const pointAY = data[a].value;
+ let maxArea = -1;
+ let nextA = rangeOffs;
+ for (let j = rangeOffs; j < rangeTo; j++) {
+ const area = Math.abs(
+ (pointAX - avgX) * (data[j].value - pointAY) -
+ (pointAX - j) * (avgY - pointAY),
+ );
+ if (area > maxArea) {
+ maxArea = area;
+ nextA = j;
+ }
+ }
+ sampled.push(data[nextA]);
+ a = nextA;
+ }
+ sampled.push(data[data.length - 1]);
+ return sampled;
+}
+
+export function useChartKeyboard(count: number) {
+ const [active, setActive] = useState(0);
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (count === 0) return;
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ event.preventDefault();
+ setActive((i) => (i + 1) % count);
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ event.preventDefault();
+ setActive((i) => (i - 1 + count) % count);
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ setActive(0);
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ setActive(count - 1);
+ }
+ },
+ [count],
+ );
+ return { active, setActive, onKeyDown };
+}
+
+export function ChartTooltip({ label, value }: { label: string; value: number }) {
+ return (
+
+ {label}: {value}
+
+ );
+}
+
+export function Legend({ series }: { series: ChartSeries[] }) {
+ return (
+
+ {series.map((s, i) => (
+
+
+ {s.name}
+
+ ))}
+
+ );
+}
+
+export function ChartTable({ series }: { series: ChartSeries[] }) {
+ return (
+