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) => ( +
  • +
  • + ))} +
+ ); +} + +export function ChartTable({ series }: { series: ChartSeries[] }) { + return ( +
+ + + + {series.map((s) => ( + + ))} + + + + {(series[0]?.data ?? []).map((d, row) => ( + + + {series.map((s) => ( + + ))} + + ))} + +
Label + {s.name} +
{d.label}{s.data[row]?.value ?? ''}
+ ); +} + +export function ChartFrame({ + title, + summary, + series, + children, + className, +}: ChartProps & { children: ReactNode }) { + const [showTable, setShowTable] = useState(false); + const summaryId = useId(); + return ( +
+ {title ?
{title}
: null} +

+ {summary} +

+
{showTable ? : children}
+ + +
+ ); +} + +export function LineChart(props: ChartProps) { + const { series, width = 480, height = 200 } = props; + const data = useMemo(() => filterFinite(series[0]?.data ?? []), [series]); + const sampled = useMemo(() => lttb(data, 500), [data]); + const { active, onKeyDown } = useChartKeyboard(sampled.length); + const x = bandScale(sampled.length, [0, width]); + const y = linearScale([0, Math.max(1, ...sampled.map((d) => d.value))], [height, 0]); + const points = sampled.map((d, i) => `${x(i)},${y(d.value)}`).join(' '); + return ( + + + + {sampled.map((d, i) => ( + + ))} + + {sampled[active] ? : null} + + ); +} + +export function AreaChart(props: ChartProps) { + const { series, width = 480, height = 200 } = props; + const data = useMemo(() => filterFinite(series[0]?.data ?? []), [series]); + const x = bandScale(data.length, [0, width]); + const y = linearScale([0, Math.max(1, ...data.map((d) => d.value))], [height, 0]); + const line = data.map((d, i) => `${x(i)},${y(d.value)}`).join(' '); + const area = `0,${height} ${line} ${width},${height}`; + return ( + + + + + + + ); +} + +export function BarChart(props: ChartProps) { + const { series, width = 480, height = 200 } = props; + const data = useMemo(() => filterFinite(series[0]?.data ?? []), [series]); + const { active, onKeyDown } = useChartKeyboard(data.length); + const x = bandScale(data.length, [0, width]); + const y = linearScale([0, Math.max(1, ...data.map((d) => d.value))], [height, 0]); + const barWidth = data.length > 0 ? width / data.length / 2 : 0; + return ( + + + {data.map((d, i) => ( + + ))} + + {data[active] ? : null} + + ); +} + +export function DonutChart(props: ChartProps) { + const { series, width = 200, height = 200 } = props; + const data = useMemo(() => filterFinite(series[0]?.data ?? []), [series]); + const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0) || 1; + const radius = Math.min(width, height) / 2 - 10; + const circumference = 2 * Math.PI * radius; + let offset = 0; + return ( + + + + {data.map((d, i) => { + const fraction = Math.max(0, d.value) / total; + const dash = fraction * circumference; + const el = ( + + ); + offset += dash; + return el; + })} + + + + ); +} + +export function Sparkline({ series, width = 120, height = 32 }: ChartProps) { + const data = useMemo(() => filterFinite(series[0]?.data ?? []), [series]); + const x = bandScale(data.length, [0, width]); + const y = linearScale([0, Math.max(1, ...data.map((d) => d.value))], [height, 0]); + const points = data.map((d, i) => `${x(i)},${y(d.value)}`).join(' '); + return ( + + + + ); +} diff --git a/src/hooks/useSolvers.ts b/src/hooks/useSolvers.ts index 6f53504..5fb3739 100644 --- a/src/hooks/useSolvers.ts +++ b/src/hooks/useSolvers.ts @@ -25,3 +25,18 @@ export function useSolvers(window: TimeWindow = "all") { return { solvers: data ?? [], isLoading, error }; } + +// The directory needs the full solver set (including inactive solvers) so it +// can offer an "inactive solvers" toggle and build the chain-capability +// matrix without re-fetching per filter change. `includeInactive` is passed +// through to the relay; the response is cached under a distinct key so the +// leaderboard's active-only list is not polluted. +export function useSolverDirectory(includeInactive = false) { + const key = includeInactive ? "/solvers?includeInactive=true" : "/solvers"; + const { data, error, isLoading } = useSWR(key, fetcher, { + refreshInterval: 30_000, + dedupingInterval: 30_000, + }); + + return { solvers: data ?? [], isLoading, error }; +}