From a71dd780760bc9765ced6968bab69c4bd10641ca Mon Sep 17 00:00:00 2001 From: nonso7 Date: Sun, 26 Jul 2026 20:30:47 +0100 Subject: [PATCH] feat(design-system): tokenize chart colours and migrate Earn, Faucet and Referrals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the semantic colour roles and shared primitives the remaining DS issues depend on, then migrates the four surfaces onto them. Tokens (packages/ui/src/styles/globals.css) - --success / --warning / --info / --destructive-foreground, plus the trading aliases --long and --short, for light and dark. - --chart-* roles (surface, text, grid, crosshair, border, up, down, liquidation) that alias those semantic tokens, so the trading chart and the rest of the app cannot drift apart. Shared primitives (packages/ui) - Text / Heading type scale, NumericText with semantic numeric roles, Stat, Card, Table, Alert, LoadingState / EmptyState / ErrorState, Spinner and LoadingButton. Badge gains success / warning / info / muted variants. - All are exercised by a new accessibility + behaviour suite and shown in the development-only design system gallery. DS-040 — trading chart colours - New typed adapter (features/trade/lib/chart-theme.ts) derives the chart palette from the CSS tokens, converting oklch() into a notation lightweight-charts can paint, and falls back per role when a token is unreadable. TVChartContainer re-themes the layout, candles and position lines on class change with no reload. 32 unit tests cover both palettes. DS-037 / DS-038 / DS-039 — Earn, Referrals and Faucet - Feature-local one-off text sizes, cards, pills, tables, alerts, spinners and empty states replaced with the shared primitives; rewards, balances, APRs, commissions and token amounts now use semantic numeric roles. - Faucet covers connected, disconnected, cooldown, mismatch, loading and error states; claim actions use LoadingButton. - Referrals stacks its sidebar below the content under lg so the layout holds at 375px; tier chips render through the shared Badge. Also repairs the @workspace/ui test setup: the vitest setup file was resolved under the wrong name and vitest-axe's matcher was never registered, so the whole package suite errored out. It now runs green (55 tests). Resolves #424 Resolves #425 Resolves #426 Resolves #427 --- .../design-system/design-system-page.tsx | 303 ++++++++++++++- .../additional-opportunities-tab.tsx | 101 +++-- .../earn/components/discover/discover-tab.tsx | 271 +++++++------ .../components/discover/opportunity-card.tsx | 89 ++--- .../distributions/distributions-tab.tsx | 60 +-- .../distributions/distributions-table.tsx | 167 ++++---- .../features/earn/components/earn-page.tsx | 7 +- .../earn/components/portfolio/assets-list.tsx | 320 ++++++++-------- .../portfolio/recommended-assets.tsx | 154 ++++---- .../earn/components/portfolio/rewards-bar.tsx | 75 ++-- .../earn/components/stake/StakeDialog.tsx | 15 +- apps/web/src/features/earn/lib/badges.ts | 21 + .../faucet/components/faucet-page.tsx | 207 +++++----- .../components/CreateReferralDialog.tsx | 40 +- .../components/affiliates/affiliates-tab.tsx | 360 +++++++++--------- .../distributions/distributions-tab.tsx | 197 +++++----- .../referrals/components/referrals-page.tsx | 13 +- .../components/referrals-sidebar.tsx | 124 +++--- .../components/shared/code-display.test.tsx | 4 +- .../components/shared/code-display.tsx | 104 ++--- .../components/shared/faq-accordion.tsx | 39 +- .../components/shared/stat-chart-card.tsx | 32 +- .../components/shared/tier-badge.tsx | 15 + .../components/shared/tier-progress.tsx | 68 ++-- .../components/shared/time-period-filter.tsx | 17 +- .../components/traders/traders-tab.tsx | 255 ++++++++----- apps/web/src/features/referrals/data/tiers.ts | 17 +- .../components/chart/TVChartContainer.tsx | 132 ++----- .../features/trade/lib/chart-theme.test.ts | 216 +++++++++++ .../web/src/features/trade/lib/chart-theme.ts | 259 +++++++++++++ packages/ui/README.md | 17 + packages/ui/axe-matchers.d.ts | 16 + packages/ui/setup-tests.ts | 7 - packages/ui/src/components/alert.tsx | 56 +++ packages/ui/src/components/badge.tsx | 9 + packages/ui/src/components/button.test.tsx | 2 +- packages/ui/src/components/card.tsx | 70 ++++ packages/ui/src/components/input.test.tsx | 2 +- packages/ui/src/components/loading-button.tsx | 45 +++ packages/ui/src/components/numeric.tsx | 81 ++++ .../ui/src/components/primitives.test.tsx | 311 +++++++++++++++ packages/ui/src/components/slider.test.tsx | 7 +- packages/ui/src/components/spinner.tsx | 28 ++ packages/ui/src/components/stat.tsx | 69 ++++ packages/ui/src/components/states.tsx | 127 ++++++ packages/ui/src/components/table.tsx | 126 ++++++ packages/ui/src/components/tabs.test.tsx | 8 +- packages/ui/src/components/text.tsx | 117 ++++++ packages/ui/src/styles/globals.css | 55 +++ packages/ui/vitest.config.ts | 3 +- packages/ui/vitest.setup.ts | 12 + 51 files changed, 3408 insertions(+), 1442 deletions(-) create mode 100644 apps/web/src/features/earn/lib/badges.ts create mode 100644 apps/web/src/features/referrals/components/shared/tier-badge.tsx create mode 100644 apps/web/src/features/trade/lib/chart-theme.test.ts create mode 100644 apps/web/src/features/trade/lib/chart-theme.ts create mode 100644 packages/ui/axe-matchers.d.ts delete mode 100644 packages/ui/setup-tests.ts create mode 100644 packages/ui/src/components/alert.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/loading-button.tsx create mode 100644 packages/ui/src/components/numeric.tsx create mode 100644 packages/ui/src/components/primitives.test.tsx create mode 100644 packages/ui/src/components/spinner.tsx create mode 100644 packages/ui/src/components/stat.tsx create mode 100644 packages/ui/src/components/states.tsx create mode 100644 packages/ui/src/components/table.tsx create mode 100644 packages/ui/src/components/text.tsx create mode 100644 packages/ui/vitest.setup.ts diff --git a/apps/web/src/features/design-system/design-system-page.tsx b/apps/web/src/features/design-system/design-system-page.tsx index 1657234..ccbc523 100644 --- a/apps/web/src/features/design-system/design-system-page.tsx +++ b/apps/web/src/features/design-system/design-system-page.tsx @@ -2,11 +2,27 @@ import { useState } from "react" import { Button } from "@workspace/ui/components/button" import { Input } from "@workspace/ui/components/input" import { Badge } from "@workspace/ui/components/badge" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@workspace/ui/components/tabs" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@workspace/ui/components/dialog" import { Skeleton } from "@workspace/ui/components/skeleton" -import { useTheme } from "@/ui/theme-provider" +import { Alert, AlertDescription, AlertTitle } from "@workspace/ui/components/alert" +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { Stat } from "@workspace/ui/components/stat" +import { EmptyState, ErrorState, LoadingState } from "@workspace/ui/components/states" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Heading, Text } from "@workspace/ui/components/text" import { Separator } from "@workspace/ui/components/separator" +import { useTheme } from "@/ui/theme-provider" function ComponentSection({ title, @@ -195,6 +211,214 @@ function TabsExamples() { ) } +function TypographyExamples() { + return ( +
+
+

Headings

+ Level 1 — page title + Level 2 — section + Level 3 — card title + Level 4 — table caption +
+ +
+

Body sizes

+ {(["2xs", "xs", "sm", "md", "base", "lg"] as const).map((size) => ( + + {size} — the quick brown fox jumps over the lazy dog + + ))} +
+ +
+

Tones

+ {(["default", "muted", "subtle", "primary", "success", "warning", "info", "danger"] as const).map( + (tone) => ( + + {tone} + + ), + )} +
+
+ ) +} + +function NumericExamples() { + return ( +
+
+

Semantic roles

+
+ $12,450.00 + $12,450.00 + +18.42% + -4.10% + $1.02 + 15% +
+
+ +
+

Stats

+
+ + + +
+
+
+ ) +} + +function SurfaceExamples() { + return ( +
+ + + Card — default surface + + + + + Card — muted surface + + + + + Card — dashed placeholder + + + + + Card — plain frame + + + + Used when the body is a table. + + + +
+ ) +} + +function TableExamples() { + return ( + + + Distribution history + + + + + Epoch + Token + Amount + Status + + + + + + W-12 + + + USDC + + + $125.50 + + + Distributed + + + + + W-13 + + + esSO4 + + + $0.00 + + + Upcoming + + + +
+
+ ) +} + +function AlertExamples() { + return ( +
+ {(["info", "success", "warning", "danger", "muted"] as const).map((variant) => ( + +
+ {variant} + + Shared alert surface — colours come from semantic tokens. + +
+
+ ))} +
+ ) +} + +function StateExamples() { + return ( +
+ + + + + + Browse pools + + } + /> + + + {}} /> + +
+ ) +} + +function LoadingButtonExamples() { + const [loading, setLoading] = useState(false) + + return ( +
+ Claim + + Claim + + { + setLoading(true) + setTimeout(() => setLoading(false), 1500) + }} + > + Try it + +
+ ) +} + export function DesignSystemPage() { const { theme, setTheme } = useTheme() const [viewport, setViewport] = useState<"mobile" | "tablet" | "desktop">("desktop") @@ -251,6 +475,27 @@ export function DesignSystemPage() { Skeletons + + Typography + + + Numeric + + + Surfaces + + + Tables + + + Alerts + + + States + + + Loading buttons + @@ -289,6 +534,60 @@ export function DesignSystemPage() { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) diff --git a/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx b/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx index 019b270..c57dede 100644 --- a/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx +++ b/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx @@ -1,14 +1,15 @@ import { useState } from "react" import { Link } from "@tanstack/react-router" import { Button } from "@workspace/ui/components/button" -import { Skeleton } from "@workspace/ui/components/skeleton" +import { Card, CardContent } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { Stat } from "@workspace/ui/components/stat" +import { Heading, Text } from "@workspace/ui/components/text" import { useUserSO4Stats } from "../../hooks/use-earn-data" import { compoundRewards, vestEsSO4 } from "../../lib/earn" import { formatToken } from "@/shared/lib/format" import { useWalletStore } from "@/features/wallet/store/wallet-store" - - function SectionCard({ title, description, @@ -21,39 +22,20 @@ function SectionCard({ children?: React.ReactNode }) { return ( -
-
-
-

{title}

-

{description}

- {children &&
{children}
} + + +
+
+ {title} + + {description} + + {children &&
{children}
} +
+
{action}
-
{action}
-
-
- ) -} - -function StatRow({ - label, - value, - isLoading, -}: { - label: string - value: string - isLoading?: boolean -}) { - return ( -
-
-

{label}

- {isLoading ? ( - - ) : ( -

{value}

- )} -
-
+ + ) } @@ -94,25 +76,27 @@ export function AdditionalOpportunitiesTab() { title="esSO4 Vesting" description="Convert esSO4 (escrowed SO4) into SO4 tokens over a 12-month linear vesting period. Tokens unlock gradually — claim anytime." action={ - + Vest now + } >
- - - + +
@@ -121,24 +105,27 @@ export function AdditionalOpportunitiesTab() { title="Multiplier Points" description="Stake SO4 continuously to earn Multiplier Points (MPs). MPs boost your staking power proportionally, increasing your fee-reward share without additional token exposure or sell pressure." action={ - + Compound + } >
- - - + +
@@ -148,16 +135,16 @@ export function AdditionalOpportunitiesTab() { description="Share your referral code to earn fee discounts and rebates. Referrers receive a percentage of their referees' trading fees, paid in USDC every epoch." action={ - } >
- - - + + +
diff --git a/apps/web/src/features/earn/components/discover/discover-tab.tsx b/apps/web/src/features/earn/components/discover/discover-tab.tsx index 069fc4f..37b18f9 100644 --- a/apps/web/src/features/earn/components/discover/discover-tab.tsx +++ b/apps/web/src/features/earn/components/discover/discover-tab.tsx @@ -1,6 +1,8 @@ import { useMemo, useState } from "react" import { cn } from "@workspace/ui/lib/utils" +import { Badge } from "@workspace/ui/components/badge" import { Button } from "@workspace/ui/components/button" +import { Card, CardContent } from "@workspace/ui/components/card" import { Dialog, DialogContent, @@ -9,10 +11,24 @@ import { DialogTitle, } from "@workspace/ui/components/dialog" import { Input } from "@workspace/ui/components/input" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { Stat } from "@workspace/ui/components/stat" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Text } from "@workspace/ui/components/text" import { usePoolsApy } from "../../hooks/use-earn-data" import { depositGLV, depositGM } from "../../lib/earn" import { useMarketPoolAmounts } from "../../hooks/useMarketPoolAmounts" import { useGLVVaultData, useGMPoolData, useStakingInfo } from "../../queries" +import { POOL_KIND_BADGE } from "../../lib/badges" import { formatPct, formatToken, formatUsd } from "@/shared/lib/format" import { fromSorobanAmount } from "@/shared/lib/bignum" import { TokenIcon } from "@/shared/components/TokenIcon" @@ -25,55 +41,47 @@ function PoolCompositionBar({ longPct, shortPct }: { longPct: number; shortPct: return (
-
-
+
+
-

+ {longPct}% / {shortPct}% -

+
) } -type FilterButtonProps = { +type ToggleButtonProps = { active: boolean onClick: () => void children: React.ReactNode } -function FilterButton({ active, onClick, children }: FilterButtonProps) { +function FilterButton({ active, onClick, children }: ToggleButtonProps) { return ( - + ) } -type SortButtonProps = { - active: boolean - onClick: () => void - children: React.ReactNode -} - -function SortButton({ active, onClick, children }: SortButtonProps) { +function SortButton({ active, onClick, children }: ToggleButtonProps) { return ( - + ) } @@ -157,12 +165,16 @@ export function DiscoverTab() {
-
- Sort +
+ }> + Sort + setSort("apy")}> APY {sort === "apy" && "↓"} - · + }> + · + setSort("tvl")}> TVL {sort === "tvl" && "↓"} @@ -171,72 +183,78 @@ export function DiscoverTab() { {/* Your deposit summary */} {stakingInfo && (stakingInfo.stakedSO4 > 0n || stakingInfo.stakedEsSO4 > 0n || stakingInfo.stakedMultiplierPoints > 0n) && ( -
-

Your Deposit

-
-
-

Staked SO4

-

{formatToken(fromSorobanAmount(stakingInfo.stakedSO4, 7), "SO4")}

-
-
-

Staked esSO4

-

{formatToken(fromSorobanAmount(stakingInfo.stakedEsSO4, 7), "esSO4")}

-
-
-

Multiplier Points

-

{formatToken(fromSorobanAmount(stakingInfo.stakedMultiplierPoints, 7), "MP")}

-
-
-

Pending Rewards

-

{formatToken(fromSorobanAmount(stakingInfo.pendingEsSO4Rewards, 7), "esSO4")}

+ + + + Your Deposit + +
+ + + +
-
-
+ + )} {/* Pool table */} -
-
- - - - - - - - - - - - - - {rows.map((row, i) => ( - - ))} - -
PoolTypeAPYTVLPositionCompositionAction
-
-
+ + + + + Pool + Type + APY + TVL + Position + Composition + Action + + + + {rows.map((row) => ( + + ))} + +
+
{/* Legend */}
-
- + } className="flex items-center gap-1.5"> + Long token -
-
- + + } className="flex items-center gap-1.5"> + Short token -
-

+ + APY based on trailing 30-day performance -

+
{/* Deposit modal */} @@ -252,15 +270,16 @@ export function DiscoverTab() { {!account ? ( -

+ Connect your wallet to deposit. -

+ ) : (
- + Promise + onEarn: (id: string, kind: "gm" | "glv", name: string) => void }) { const { data: poolAmounts } = useMarketPoolAmounts(row.marketAddress) const { data: gmPoolData } = useGMPoolData(row.marketAddress) @@ -332,46 +349,48 @@ function DiscoverRow({ : fromSorobanAmount(glvVaultData?.userGlvBalance ?? 0n, 7) return ( - - + +
{row.name}
- - - - {row.kind.toUpperCase()} - - - - {formatPct(apy, { sign: false })} - - - {formatUsd(tvl, { compact: true })} - - - {userBalance > 0 ? userBalance.toLocaleString(undefined, { maximumFractionDigits: 4 }) : "0"} - - +
+ + {row.kind.toUpperCase()} + + + + {formatPct(apy, { sign: false })} + + + + {formatUsd(tvl, { compact: true })} + + + + {userBalance > 0 ? userBalance.toLocaleString(undefined, { maximumFractionDigits: 4 }) : "0"} + + + {longPct !== undefined ? ( ) : ( - Diversified + }> + Diversified + )} - - - - - + + + onEarn(row.id, row.kind, row.name)} + > + Earn + + +
) } diff --git a/apps/web/src/features/earn/components/discover/opportunity-card.tsx b/apps/web/src/features/earn/components/discover/opportunity-card.tsx index 6eeabca..672dabf 100644 --- a/apps/web/src/features/earn/components/discover/opportunity-card.tsx +++ b/apps/web/src/features/earn/components/discover/opportunity-card.tsx @@ -1,5 +1,8 @@ -import { cn } from "@workspace/ui/lib/utils" import { Button } from "@workspace/ui/components/button" +import { Card, CardContent } from "@workspace/ui/components/card" +import { Stat } from "@workspace/ui/components/stat" +import { Text } from "@workspace/ui/components/text" +import { cn } from "@workspace/ui/lib/utils" import { formatPct, formatUsd } from "@/shared/lib/format" import { TokenIcon } from "@/shared/components/TokenIcon" @@ -23,52 +26,52 @@ export function OpportunityCard({ actionLabel = "Earn", }: OpportunityCardProps) { return ( -
-
-
-
- {tokens.map((symbol, i) => ( - - ))} + + +
+
+
+ {tokens.map((symbol, i) => ( + + ))} +
+ }> + {name} +
- {name}
-
-
-
-

APR

-

- {formatPct(apy, { sign: false })} -

-
-
-

TVL

-

- {formatUsd(tvlUsd, { compact: true })} -

+
+ +
-
- -
+ + + ) } diff --git a/apps/web/src/features/earn/components/distributions/distributions-tab.tsx b/apps/web/src/features/earn/components/distributions/distributions-tab.tsx index bc11b99..e468879 100644 --- a/apps/web/src/features/earn/components/distributions/distributions-tab.tsx +++ b/apps/web/src/features/earn/components/distributions/distributions-tab.tsx @@ -1,3 +1,6 @@ +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card" +import { Stat } from "@workspace/ui/components/stat" +import { Heading, Text } from "@workspace/ui/components/text" import { DistributionsTable, type DistributionRow } from "./distributions-table" // TODO: Replace with live data fetched from Stellar event log or subgraph: @@ -6,30 +9,33 @@ import { DistributionsTable, type DistributionRow } from "./distributions-table" // - Fields: epochId, timestamp, tokenAmount, tokenAddress, txHash const MOCK_DISTRIBUTIONS: DistributionRow[] = [] +const SCHEDULE_FACTS = [ + { label: "Distribution cycle", value: "Weekly" }, + { label: "Fee allocation", value: "70% to stakers" }, + { label: "Remaining", value: "27% Treasury" }, + { label: "Protocol", value: "3% team" }, +] + function InfoCard() { return ( -
-

Fee Distribution Schedule

-

- Protocol fees are collected continuously and distributed weekly to SO4 stakers and - liquidity providers. Your share is proportional to your staking power (staked amount × - duration multiplier). USDC fees are distributed directly; platform fees are used for - buybacks and distributed as esSO4. -

-
- {[ - { label: "Distribution cycle", value: "Weekly" }, - { label: "Fee allocation", value: "70% to stakers" }, - { label: "Remaining", value: "27% Treasury" }, - { label: "Protocol", value: "3% team" }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
-
+ + + + Fee Distribution Schedule + + + Protocol fees are collected continuously and distributed weekly to SO4 stakers and + liquidity providers. Your share is proportional to your staking power (staked amount × + duration multiplier). USDC fees are distributed directly; platform fees are used for + buybacks and distributed as esSO4. + +
+ {SCHEDULE_FACTS.map(({ label, value }) => ( + + ))} +
+
+
) } @@ -38,12 +44,12 @@ export function DistributionsTab() {
-
-
-

Distribution History

-
+ + + Distribution History + -
+
) } diff --git a/apps/web/src/features/earn/components/distributions/distributions-table.tsx b/apps/web/src/features/earn/components/distributions/distributions-table.tsx index 43d2426..7120ea4 100644 --- a/apps/web/src/features/earn/components/distributions/distributions-table.tsx +++ b/apps/web/src/features/earn/components/distributions/distributions-table.tsx @@ -1,4 +1,17 @@ -import { cn } from "@workspace/ui/lib/utils" +import { Badge } from "@workspace/ui/components/badge" +import { NumericText } from "@workspace/ui/components/numeric" +import { EmptyState } from "@workspace/ui/components/states" +import { + Table, + TableBody, + TableCell, + TableEmptyRow, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import type { BadgeVariant } from "../../lib/badges" import { formatUsd } from "@/shared/lib/format" // ── Types ──────────────────────────────────────────────────────────────────── @@ -19,15 +32,15 @@ export type DistributionRow = { txHash?: string } -// ── Styling maps ───────────────────────────────────────────────────────────── +// ── Status mapping ─────────────────────────────────────────────────────────── -const STATUS_STYLES: Record = { - distributed: "bg-green-500/10 text-green-400 border-green-500/20", - pending: "bg-amber-500/10 text-amber-400 border-amber-500/20", - upcoming: "bg-muted/60 text-muted-foreground border-border", - claim: "bg-violet-500/10 text-violet-400 border-violet-500/20", - claimed: "bg-green-500/10 text-green-400 border-green-500/20", -} +const STATUS_VARIANT = { + distributed: "success", + pending: "warning", + upcoming: "muted", + claim: "info", + claimed: "success", +} as const satisfies Record const STATUS_LABEL: Record = { distributed: "Distributed", @@ -37,26 +50,11 @@ const STATUS_LABEL: Record = { claimed: "Claimed", } -// ── Sub-components ─────────────────────────────────────────────────────────── - -function StatusBadge({ status }: { status: DistributionStatus }) { - return ( - - {STATUS_LABEL[status]} - - ) -} - // ── Main component ─────────────────────────────────────────────────────────── type DistributionsTableProps = { /** Rows to render. An empty array triggers the empty-state message. */ - distributions: DistributionRow[] + distributions: Array /** Fires when the user clicks a "Claim" badge. Receives the epoch id. */ onClaim?: (epochId: string) => void } @@ -72,68 +70,61 @@ type DistributionsTableProps = { */ export function DistributionsTable({ distributions, onClaim }: DistributionsTableProps) { return ( -
- - - - - - - - - - - - - {distributions.length > 0 ? ( - distributions.map((row) => ( - - - - - - - - - )) - ) : ( - - - - )} - -
EpochDateAmountTokenStatusTx
{row.epoch}{row.date}{formatUsd(row.amountUsd)}{row.token} - {row.status === "claim" ? ( - - ) : ( - - )} - - {row.txHash ? ( - - {row.txHash.slice(0, 8)}… - - ) : ( - - )} -
-

No distributions yet

-

- Your distribution history will appear here once the protocol goes live -

-
-
+ + + + Epoch + Date + Amount + Token + Status + Tx + + + + {distributions.length > 0 ? ( + distributions.map((row) => ( + + + {row.epoch} + + {row.date} + + {formatUsd(row.amountUsd)} + + + {row.token} + + + {row.status === "claim" ? ( + onClaim?.(row.epoch)} />} + > + Claim + + ) : ( + + {STATUS_LABEL[row.status]} + + )} + + + + {row.txHash ? `${row.txHash.slice(0, 8)}…` : "—"} + + + + )) + ) : ( + + + + )} + +
) } diff --git a/apps/web/src/features/earn/components/earn-page.tsx b/apps/web/src/features/earn/components/earn-page.tsx index 0371d77..9371a47 100644 --- a/apps/web/src/features/earn/components/earn-page.tsx +++ b/apps/web/src/features/earn/components/earn-page.tsx @@ -1,4 +1,5 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" +import { Heading, Text } from "@workspace/ui/components/text" import { Navbar } from "../../../ui/Navbar" import { PortfolioTab } from "./portfolio/portfolio-tab" import { DiscoverTab } from "./discover/discover-tab" @@ -11,10 +12,10 @@ export function EarnPage() {
-

Earn

-

+ Earn + Stake SO4 and buy GLV or GM to earn rewards -

+
diff --git a/apps/web/src/features/earn/components/portfolio/assets-list.tsx b/apps/web/src/features/earn/components/portfolio/assets-list.tsx index c61921f..68368f2 100644 --- a/apps/web/src/features/earn/components/portfolio/assets-list.tsx +++ b/apps/web/src/features/earn/components/portfolio/assets-list.tsx @@ -1,13 +1,24 @@ import { useState } from "react" -import { cn } from "@workspace/ui/lib/utils" -import { Button } from "@workspace/ui/components/button" -import { Skeleton } from "@workspace/ui/components/skeleton" +import { Badge } from "@workspace/ui/components/badge" +import { Card, CardHeader } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { EmptyState, LoadingState } from "@workspace/ui/components/states" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Heading, Text } from "@workspace/ui/components/text" import { useUserGlvPositions, useUserGmPositions, useUserSO4Stats } from "../../hooks/use-earn-data" import { unstakeSO4, withdrawGLV, withdrawGM } from "../../lib/earn" +import { ASSET_KIND_BADGE } from "../../lib/badges" import { formatPct, formatUsd } from "@/shared/lib/format" - - function WalletEmptyIcon() { return ( @@ -30,58 +40,8 @@ function WalletEmptyIcon() { ) } -function EmptyState() { - return ( -
-
- -
-
-

You have no deposits

-

- Start earning by depositing into a pool -

- - Browse pools → - -
-
- ) -} - -function LoadingRows() { - return ( -
- {[0, 1].map((i) => ( -
- - - - - -
- ))} -
- ) -} - -const KIND_BADGE: Record = { - Staking: "bg-violet-500/10 text-violet-400 border-violet-500/20", - GM: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", - GLV: "bg-teal-500/10 text-teal-400 border-teal-500/20", -} - -function TypeBadge({ kind }: { kind: string }) { - return ( - - {kind} - - ) +function TypeBadge({ kind }: { kind: keyof typeof ASSET_KIND_BADGE }) { + return {kind} } export function AssetsList() { @@ -104,125 +64,147 @@ export function AssetsList() { } return ( -
-
-

My assets

-
+ + + My assets + {isLoading ? ( - + ) : !hasAny ? ( - + } + title="You have no deposits" + description="Start earning by depositing into a pool" + action={ + } + className="hover:text-primary/80" + > + Browse pools → + + } + /> ) : ( -
- - - - - - - - - - - {hasSO4 && ( - - - - - - - - )} +
AssetTypeAPYValue -
SO4 - - - {formatUsd(so4Stats.stakedValueUsd)} - - -
+ + + Asset + Type + APY + Value + + + + + {hasSO4 && ( + + SO4 + + + + + + + + {formatUsd(so4Stats.stakedValueUsd)} + + + + void runAction("so4", () => + unstakeSO4("DUMMY_ACCOUNT", so4Stats.stakedAmount), + ) + } + > + Unstake + + + + )} - {gmPositions.map((pos) => ( - - - - - - - - ))} + + + + {formatUsd(pos.balanceUsd)} + + + + void runAction(pos.poolId, () => + withdrawGM("DUMMY_ACCOUNT", pos.poolName, pos.balanceTokens), + ) + } + > + Sell + + + + ))} - {glvPositions.map((pos) => ( - - - - - - - - ))} - -
{pos.poolName} - - + {gmPositions.map((pos) => ( + + {pos.poolName} + + + + + {formatPct(pos.apy, { sign: false })} - {formatUsd(pos.balanceUsd)} - -
- {pos.vaultName}{" "} - [{pos.displayPair}] - - - + {glvPositions.map((pos) => ( + + + {pos.vaultName}{" "} + }> + [{pos.displayPair}] + + + + + + + {formatPct(pos.apy, { sign: false })} - {formatUsd(pos.balanceUsd)} - -
-
+ + + + {formatUsd(pos.balanceUsd)} + + + + void runAction(pos.vaultId, () => + withdrawGLV( + "DUMMY_ACCOUNT", + `${pos.vaultName} [${pos.displayPair}]`, + pos.balanceTokens, + ), + ) + } + > + Sell + + + + ))} + + )} -
+ ) } diff --git a/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx b/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx index 487b5d1..d6c03a0 100644 --- a/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx +++ b/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx @@ -1,41 +1,14 @@ import { useState } from "react" -import { cn } from "@workspace/ui/lib/utils" import { Button } from "@workspace/ui/components/button" +import { Card } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { Heading, Text } from "@workspace/ui/components/text" +import { TokenAvatar } from "@workspace/ui/components/token-avatar" import { GLV_VAULTS, GM_POOLS } from "../../data/pools" import { buySO4, depositGLV, depositGM } from "../../lib/earn" import { formatPct } from "@/shared/lib/format" -const TOKEN_COLORS: Record = { - BTC: "bg-orange-500/10 text-orange-400 ring-orange-500/20", - ETH: "bg-indigo-500/10 text-indigo-400 ring-indigo-500/20", - XLM: "bg-sky-500/10 text-sky-400 ring-sky-500/20", - USDC: "bg-blue-500/10 text-blue-400 ring-blue-500/20", - GLV: "bg-teal-500/10 text-teal-400 ring-teal-500/20", - SO4: "bg-primary/10 text-primary ring-primary/20", -} - -function TokenAvatar({ - symbol, - size = "md", -}: { - symbol: string - size?: "sm" | "md" | "lg" -}) { - const color = TOKEN_COLORS[symbol] ?? "bg-muted/60 text-muted-foreground ring-border" - const dimensions = { sm: "h-7 w-7 text-[10px]", md: "h-9 w-9 text-[11px]", lg: "h-11 w-11 text-sm" } - return ( -
- {symbol.slice(0, 2)} -
- ) -} - function LightningIcon() { return ( @@ -80,6 +53,14 @@ function SO4LogoIcon() { ) } +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ) +} + function SO4Card() { const [pending, setPending] = useState(false) @@ -90,28 +71,31 @@ function SO4Card() { } return ( -
-

- SO4 -

+ + SO4
-
+
-

SO4

-

Accumulating…

+ + SO4 + + + Accumulating… +
- -
+ +
) } @@ -130,32 +114,37 @@ function GlvCard() { } return ( -
-

- GLV vaults -

+ + GLV vaults
- +
-

+ {vault.name}{" "} - [{vault.displayPair}] -

+ }> + [{vault.displayPair}] + +
- {formatPct(vault.apy, { sign: false })} - Performance APY + + {formatPct(vault.apy, { sign: false })} + + }> + Performance APY +
- + Earn +
-
+ ) } @@ -174,15 +163,13 @@ function GmCard() { } return ( -
+
-

- GM pools -

- +
@@ -190,27 +177,34 @@ function GmCard() {
-

{pool.name}

-

+ + {pool.name} + + [{pool.longToken}-{pool.shortToken}] -

+
-

{formatPct(pool.apy, { sign: false })}

-

Performance APY

+ + {formatPct(pool.apy, { sign: false })} + + + Performance APY +
- + Earn +
))}
-
+ ) } @@ -219,7 +213,7 @@ export function RecommendedAssets() {
-

Recommended

+ Recommended
diff --git a/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx b/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx index 38d0167..320ad9c 100644 --- a/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx +++ b/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx @@ -1,35 +1,13 @@ import { useState } from "react" -import { Button } from "@workspace/ui/components/button" -import { Skeleton } from "@workspace/ui/components/skeleton" +import { Alert, AlertDescription } from "@workspace/ui/components/alert" +import { Card } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { Separator } from "@workspace/ui/components/separator" +import { Stat } from "@workspace/ui/components/stat" import { useEarnStats } from "../../hooks/use-earn-data" import { claimRewards } from "../../lib/earn" import { formatPct, formatUsd } from "@/shared/lib/format" - - -function StatItem({ - label, - value, - isLoading, - mono = true, -}: { - label: string - value: string - isLoading?: boolean - mono?: boolean -}) { - return ( -
- {label} - {isLoading ? ( - - ) : ( - {value} - )} -
- ) -} - function InfoIcon() { return ( {bannerOpen && ( -
+ -

+ Protocol fees are accumulating in the Treasury for SO4 buybacks. Rewards will be distributed to stakers proportional to staking power{" "} (duration × amount staked) when the buyback threshold is reached. -

+ -
+ )} -
- + -
+ - 0 ? "positive" : "neutral"} isLoading={isLoading} /> -
+ - -
+ -
- + Claim rewards +
-
+
) } diff --git a/apps/web/src/features/earn/components/stake/StakeDialog.tsx b/apps/web/src/features/earn/components/stake/StakeDialog.tsx index f356928..3ff626d 100644 --- a/apps/web/src/features/earn/components/stake/StakeDialog.tsx +++ b/apps/web/src/features/earn/components/stake/StakeDialog.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from "@workspace/ui/components/dialog" import { Button } from "@workspace/ui/components/button" +import { LoadingButton } from "@workspace/ui/components/loading-button" import { Input } from "@workspace/ui/components/input" import { useStakeMutation } from "../../hooks/useStakeMutation" @@ -96,17 +97,15 @@ export function StakeDialog({
)} - + {action === "stake" ? "Stake SO4" : "Unstake SO4"} +
diff --git a/apps/web/src/features/earn/lib/badges.ts b/apps/web/src/features/earn/lib/badges.ts new file mode 100644 index 0000000..0417129 --- /dev/null +++ b/apps/web/src/features/earn/lib/badges.ts @@ -0,0 +1,21 @@ +import type { BadgeVariant } from "@workspace/ui/components/badge" + +/** + * Asset-kind → shared Badge variant. + * + * Keeps the three earn products visually distinct without any feature-local + * colour classes: every hue comes from a semantic token. + */ +export const ASSET_KIND_BADGE = { + Staking: "info", + GM: "success", + GLV: "secondary", +} as const satisfies Record + +/** Badge variant for a pool row keyed by its `kind` discriminator. */ +export const POOL_KIND_BADGE = { + gm: ASSET_KIND_BADGE.GM, + glv: ASSET_KIND_BADGE.GLV, +} as const satisfies Record<"gm" | "glv", BadgeVariant> + +export type { BadgeVariant } diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index 1ab2f27..645488b 100644 --- a/apps/web/src/features/faucet/components/faucet-page.tsx +++ b/apps/web/src/features/faucet/components/faucet-page.tsx @@ -1,5 +1,10 @@ -import { Skeleton } from "@workspace/ui/components/skeleton" -import { Button } from "@workspace/ui/components/button" +import { Alert, AlertDescription } from "@workspace/ui/components/alert" +import { Badge } from "@workspace/ui/components/badge" +import { Card, CardContent } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { Stat } from "@workspace/ui/components/stat" +import { Heading, Text } from "@workspace/ui/components/text" import { FAUCET_TOKENS, type FaucetTokenConfig } from "../data/tokens" // eslint-disable-line import/consistent-type-specifier-style import { FAUCET_CONTRACT_ID } from "../lib/clients" import { useFaucetData } from "../hooks/useFaucetData" @@ -44,63 +49,56 @@ function TokenCard({ : "No claim recorded" return ( -
+
-
-

{token.symbol}

-

{token.name}

+
+ + {token.symbol} + + + {token.name} +
-

- Your balance -

- {isLoading ? ( - - ) : ( -

- {formatToken(balance, token.symbol, { decimals: 4 })} -

- )} +
-

- Claim amount -

- {isLoading ? ( - - ) : ( -

- {formatToken(claimAmount, token.symbol, { decimals: 2 })} -

- )} +
-

{cooldownText}

- + Claim +
-
+
) } @@ -124,24 +122,26 @@ export function FaucetPage() {
{/* Header */}
-
-

Testnet Faucet

- - +
+ Testnet Faucet + + Stellar Testnet - +
-

+ Claim test tokens to try trading on SO4. Tokens have no real value. -

+
{!isTestnet ? ( -
-

- The faucet is only available on the Stellar testnet. -

-
+ + + + The faucet is only available on the Stellar testnet. + + + ) : (
{/* Token cards */} @@ -163,75 +163,86 @@ export function FaucetPage() {
{/* Claim panel */} -
-
+ +
-

Claim test tokens

-

+ + Claim test tokens + + Receive TUSDC, TWBTC, TETH, and TXLM in a single transaction. A cooldown applies between claims. -

+
{mismatch && ( -
- Switch your wallet to Stellar Testnet to claim. -
+ + + Switch your wallet to Stellar Testnet to claim. + + )} {!isConnected ? (
-

Connect your wallet to claim test tokens.

+ + Connect your wallet to claim test tokens. +
) : ( - + Claim Test Tokens + )} {data?.cooldownLedgers != null && data.cooldownLedgers > 0 && ( -

+ Cooldown: {data.cooldownLedgers.toLocaleString()} ledgers between claims -

+ )} -
-
+ + {/* Info panel */} -
-

- Contract addresses -

-
- {[ - { label: "Faucet", id: FAUCET_CONTRACT_ID }, - ...FAUCET_TOKENS.map((token) => ({ - label: token.symbol, - id: token.contractId, - })), - ].map(({ label, id }) => ( -
-
{label}
-
- {id} -
-
- ))} -
-
+ + + + Contract addresses + +
+ {[ + { label: "Faucet", id: FAUCET_CONTRACT_ID }, + ...FAUCET_TOKENS.map((token) => ({ + label: token.symbol, + id: token.contractId, + })), + ].map(({ label, id }) => ( +
+ } className="w-12 shrink-0"> + {label} + + } + className="min-w-0 truncate" + > + {id} + +
+ ))} +
+
+
)}
diff --git a/apps/web/src/features/referrals/components/CreateReferralDialog.tsx b/apps/web/src/features/referrals/components/CreateReferralDialog.tsx index 7e5718e..6bac505 100644 --- a/apps/web/src/features/referrals/components/CreateReferralDialog.tsx +++ b/apps/web/src/features/referrals/components/CreateReferralDialog.tsx @@ -5,8 +5,9 @@ import { DialogHeader, DialogTitle, } from "@workspace/ui/components/dialog" -import { Button } from "@workspace/ui/components/button" +import { LoadingButton } from "@workspace/ui/components/loading-button" import { Input } from "@workspace/ui/components/input" +import { Text } from "@workspace/ui/components/text" import { validateReferralCode } from "../lib/referrals" import { useCreateReferralCodeMutation } from "../hooks/useCreateReferralCodeMutation" @@ -59,28 +60,41 @@ export function CreateReferralDialog({
- + } + className="mb-2 block" + > + Referral Code + - {error &&

{error}

} - {!error && ( -

- Minimum 3 characters. Only letters, numbers, and underscores allowed. -

- )} + + {error ?? "Minimum 3 characters. Only letters, numbers, and underscores allowed."} +
- + Create Code +
diff --git a/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx b/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx index 60fee9e..4df15a5 100644 --- a/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx +++ b/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx @@ -1,23 +1,38 @@ import { useState } from "react" import { useQueryClient } from "@tanstack/react-query" -import { Button } from "@workspace/ui/components/button" +import { Alert, AlertDescription, AlertTitle } from "@workspace/ui/components/alert" +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card" +import { Input } from "@workspace/ui/components/input" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" import { Skeleton } from "@workspace/ui/components/skeleton" -import { cn } from "@workspace/ui/lib/utils" +import { Stat } from "@workspace/ui/components/stat" +import { EmptyState, LoadingState } from "@workspace/ui/components/states" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Heading, Text } from "@workspace/ui/components/text" import { useAffiliateReferrals } from "../../hooks/use-referrals-data" import { useReferralCode } from "../../queries/useReferralCode" import { useReferralStats } from "../../queries/useReferralStats" import { useReferralTier } from "../../queries/useReferralTier" import { createAffiliateCode, validateReferralCode } from "../../lib/referrals" -import { TIERS, getNextTier, getTierByLevel } from "../../data/tiers" +import { TIERS } from "../../data/tiers" import { TimePeriodFilter } from "../shared/time-period-filter" import { StatChartCard } from "../shared/stat-chart-card" +import { TierBadge } from "../shared/tier-badge" +import { TierProgress } from "../shared/tier-progress" import type { TimePeriod } from "../../hooks/use-referrals-data" import { formatAddress, formatUsd } from "@/shared/lib/format" import { queryKeys } from "@/shared/lib/query-keys" import { useWalletStore } from "@/features/wallet/store/wallet-store" - - // ── Create code wizard ────────────────────────────────────────────────────── function CreateCodeForm({ onSuccess }: { onSuccess: () => void }) { @@ -47,197 +62,170 @@ function CreateCodeForm({ onSuccess }: { onSuccess: () => void }) { } return ( -
- {/* How it works */} -
-
- - - - - - -
-
-

Create a code and start earning commissions

-

- Earn up to 15% of trading fees - from every user who joins with your code. Tier up as your referrals grow. -

-
-
+ + + {/* How it works */} + + + + + + + + + +
+ Create a code and start earning commissions + + Earn up to 15% of trading fees from every + user who joins with your code. Tier up as your referrals grow. + +
+
-
-
- -
- { - setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "")) - setError(null) - }} - placeholder="e.g. MYCODE123" - maxLength={16} - autoComplete="off" - spellCheck={false} - className="flex h-9 w-full rounded-lg border border-border bg-muted/30 px-3 font-mono text-[13px] tracking-widest placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30" - /> - -
-
- {error - ?

{error}

- :

Letters, numbers, and underscores only. Max 16 chars.

- } - {code.length}/16 + Choose your referral code + +
+ { + setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "")) + setError(null) + }} + placeholder="e.g. MYCODE123" + maxLength={16} + autoComplete="off" + spellCheck={false} + aria-invalid={error ? true : undefined} + className="h-9 font-mono tracking-widest placeholder:font-sans placeholder:tracking-normal" + /> + + Create + +
+
+ {error + ? {error} + : Letters, numbers, and underscores only. Max 16 chars. + } + + {code.length}/16 + +
-
-
+ - {/* Tier table */} -
-

- Commission tiers -

-
- - - - - - - - - - - {TIERS.map((tier, i) => ( - - - - - - - ))} - -
TierVolume (30d)CommissionTrader discount
- - {tier.label} - - - {tier.minVolumeUsd === 0 ? "Any" : `≥ ${formatUsd(tier.minVolumeUsd, { compact: true })}`} - - {tier.affiliateCommissionPct}% - - {tier.traderDiscountPct}% -
+ {/* Tier table */} +
+ + Commission tiers + + + + + + Tier + Volume (30d) + Commission + Trader discount + + + + {TIERS.map((tier) => ( + + + + + + + {tier.minVolumeUsd === 0 ? "Any" : `≥ ${formatUsd(tier.minVolumeUsd, { compact: true })}`} + + + + + {tier.affiliateCommissionPct}% + + + + + {tier.traderDiscountPct}% + + + + ))} + +
+
-
-
+
+
) } // ── Dashboard (when code exists) ──────────────────────────────────────────── -function TierProgress({ tier, volumeUsd }: { tier: 1 | 2 | 3; volumeUsd: number }) { - const current = getTierByLevel(tier) - const next = getNextTier(tier) - - if (!next) { - return ( -
- - {current.label} - - Maximum tier reached! -
- ) - } - - const progress = Math.min((volumeUsd / next.minVolumeUsd) * 100, 100) - const remaining = Math.max(next.minVolumeUsd - volumeUsd, 0) - - return ( -
-
-
- - {current.label} - - - - {next.label} - -
- - {formatUsd(remaining, { compact: true })} more needed - -
-
-
-
-
- ) -} - function ReferralsTable() { const { data: referrals = [], isLoading } = useAffiliateReferrals() return ( -
-
-

Referrals

-
+ + + Referrals + {isLoading ? ( -
- - -
+ ) : referrals.length === 0 ? ( -
-

No referrals yet

-

- Share your code to start earning commissions -

-
+ ) : ( -
- - - - - - - - - - - {referrals.map((r) => ( - - - - - - - ))} - -
AccountVolumeCommissionSince
{formatAddress(r.account)}{formatUsd(r.volumeUsd, { compact: true })}{formatUsd(r.commissionUsd, { compact: true })}{r.registeredAt}
-
+ + + + Account + Volume + Commission + Since + + + + {referrals.map((r) => ( + + + {formatAddress(r.account)} + + + {formatUsd(r.volumeUsd, { compact: true })} + + + + {formatUsd(r.commissionUsd, { compact: true })} + + + {r.registeredAt} + + ))} + +
)} -
+ ) } @@ -266,23 +254,27 @@ export function AffiliatesTab() { {/* Overview */}
-
-

Overview

+
+ Overview
{isLoading ? ( -
+
) : (
-
- Total referrals - {stats?.totalTraders ?? 0} -
+ + + + + + + ) +} export function DistributionsTab() { const account = useWalletStore((state) => state.address) @@ -28,101 +55,93 @@ export function DistributionsTab() { if (!hasAffiliateCode && !isLoading) { return ( -
-
- - - - -
-
-

Register an affiliate code to access distributions

-

- Switch to the Affiliates tab and create your code to unlock this section. -

-
-
+ + } + title="Register an affiliate code to access distributions" + description="Switch to the Affiliates tab and create your code to unlock this section." + /> + ) } return (
{/* Info card */} -
-

Commission Distributions

-

- Commissions from your referrals' trading fees are distributed weekly every Thursday. - Payments are made in USDC directly to your wallet. Unclaimed distributions accumulate - and can be claimed at any time. -

-
- {[ - { label: "Distribution cycle", value: "Weekly (Thu)" }, - { label: "Payment token", value: "USDC" }, - { label: "Claim window", value: "No expiry" }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
-
+ + + + Commission Distributions + + + Commissions from your referrals' trading fees are distributed weekly every Thursday. + Payments are made in USDC directly to your wallet. Unclaimed distributions accumulate + and can be claimed at any time. + +
+ {SCHEDULE_FACTS.map(({ label, value }) => ( + + ))} +
+
+
{/* Distributions table */} -
-
-

History

-
-
- - - - - - - - - - - - - {distributions.length > 0 ? ( - distributions.map((d) => ( - - - - - - - - - )) - ) : ( - - - - )} - -
EpochDateAmountTokenUSD valueAction
{d.epoch}{d.date}{formatToken(d.amount, d.token)}{d.token}{formatUsd(d.amountUsd)} - -
-

No distributions yet

-

- Commissions will appear here after your first weekly distribution -

-
-
-
+ + + History + + + + + Epoch + Date + Amount + Token + USD value + Action + + + + {distributions.length > 0 ? ( + distributions.map((d) => ( + + + {d.epoch} + + {d.date} + + {formatToken(d.amount, d.token)} + + + {d.token} + + + {formatUsd(d.amountUsd)} + + + void handleClaim(d.id)} + > + Claim + + + + )) + ) : ( + + + + )} + +
+
) } diff --git a/apps/web/src/features/referrals/components/referrals-page.tsx b/apps/web/src/features/referrals/components/referrals-page.tsx index 4c2b2c3..8553871 100644 --- a/apps/web/src/features/referrals/components/referrals-page.tsx +++ b/apps/web/src/features/referrals/components/referrals-page.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" +import { Heading, Text } from "@workspace/ui/components/text" import { useQueryClient } from "@tanstack/react-query" import { Navbar } from "../../../ui/Navbar" import { useTraderStats } from "../hooks/use-referrals-data" @@ -49,10 +50,10 @@ export function ReferralsPage() {
-

Referrals

-

+ Referrals + Get fee discounts and earn up to 15% commission through the SO4 referral program -

+
setTab(v as ReferralsTab)} className="gap-6" > - + Traders Affiliates - {/* 2-column: tab content (flex-1) + sticky sidebar (w-72) */} -
+ {/* Stacks on mobile; 2-column (content + sticky sidebar) from lg up */} +
= [ @@ -46,11 +48,11 @@ type ExternalLinkProps = { href: string; children: React.ReactNode } function ExternalLink({ href, children }: ExternalLinkProps) { return ( - } + className="flex items-center justify-between py-2.5 transition-colors hover:text-foreground" > {children} - + ) } @@ -92,7 +94,7 @@ export function ReferralsSidebar({ const faqs = isAffiliate ? AFFILIATE_FAQS : TRADER_FAQS return ( -
+ + {onEdit && ( + + )} +
+ + ) } diff --git a/apps/web/src/features/referrals/components/shared/faq-accordion.tsx b/apps/web/src/features/referrals/components/shared/faq-accordion.tsx index b457e74..4b6cc75 100644 --- a/apps/web/src/features/referrals/components/shared/faq-accordion.tsx +++ b/apps/web/src/features/referrals/components/shared/faq-accordion.tsx @@ -1,4 +1,6 @@ -import { useState } from "react" +import { useId, useState } from "react" +import { Card, CardContent } from "@workspace/ui/components/card" +import { Text } from "@workspace/ui/components/text" import { cn } from "@workspace/ui/lib/utils" export type FaqItem = { @@ -25,24 +27,33 @@ function ChevronIcon({ open }: { open: boolean }) { function AccordionItem({ item }: { item: FaqItem }) { const [open, setOpen] = useState(false) + const panelId = useId() return (
-

{item.a}

+ + {item.a} +
) @@ -55,15 +66,17 @@ type Props = { export function FaqAccordion({ items, title = "FAQ" }: Props) { return ( -
-

- {title} -

-
- {items.map((item) => ( - - ))} -
-
+ + + + {title} + +
+ {items.map((item) => ( + + ))} +
+
+
) } diff --git a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx index 9dab628..bf75c77 100644 --- a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx +++ b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx @@ -1,8 +1,10 @@ +import { Card } from "@workspace/ui/components/card" +import { NumericText } from "@workspace/ui/components/numeric" +import { Text } from "@workspace/ui/components/text" +import type { NumericRole } from "@workspace/ui/components/numeric" import type { TimePeriod } from "../../hooks/use-referrals-data" import { formatUsd } from "@/shared/lib/format" - - function xAxisLabels(period: TimePeriod): Array { const now = new Date() const fmt = (d: Date) => @@ -38,30 +40,40 @@ function InfoIcon({ className }: InfoIconProps) { ) } +type Accent = "green" | "blue" + +/** Accent → semantic token + matching numeric role. */ +const ACCENTS: Record = { + green: { stroke: "var(--success)", role: "positive" }, + blue: { stroke: "var(--info)", role: "neutral" }, +} + type Props = { title: string tooltip: string value: number period: TimePeriod - accent?: "green" | "blue" + accent?: Accent } export function StatChartCard({ title, tooltip, value, period, accent = "blue" }: Props) { const labels = xAxisLabels(period) - const accentColor = accent === "green" ? "#4ade80" : "#60a5fa" + const { stroke, role } = ACCENTS[accent] return ( -
+
- {title} + }> + {title} +
-

+ {formatUsd(value)} -

+
{/* Chart area */} @@ -105,7 +117,7 @@ export function StatChartCard({ title, tooltip, value, period, accent = "blue" } y1={80} x2={396} y2={80} - stroke={accentColor} + stroke={stroke} strokeOpacity={0.4} strokeWidth={1.5} strokeLinecap="round" @@ -130,6 +142,6 @@ export function StatChartCard({ title, tooltip, value, period, accent = "blue" } })}
-
+ ) } diff --git a/apps/web/src/features/referrals/components/shared/tier-badge.tsx b/apps/web/src/features/referrals/components/shared/tier-badge.tsx new file mode 100644 index 0000000..8a52415 --- /dev/null +++ b/apps/web/src/features/referrals/components/shared/tier-badge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@workspace/ui/components/badge" +import { cn } from "@workspace/ui/lib/utils" +import type { Tier } from "../../data/tiers" + +/** + * Tier pill. Wraps the shared `Badge` so every tier chip in the referrals + * feature shares one shape, size and focus treatment. + */ +export function TierBadge({ tier, className }: { tier: Tier; className?: string }) { + return ( + + {tier.label} + + ) +} diff --git a/apps/web/src/features/referrals/components/shared/tier-progress.tsx b/apps/web/src/features/referrals/components/shared/tier-progress.tsx index 9bedc19..b043934 100644 --- a/apps/web/src/features/referrals/components/shared/tier-progress.tsx +++ b/apps/web/src/features/referrals/components/shared/tier-progress.tsx @@ -1,5 +1,9 @@ -import { cn } from "@workspace/ui/lib/utils" -import { getTierByLevel, getNextTier } from "../../data/tiers" +import { Alert } from "@workspace/ui/components/alert" +import { Card } from "@workspace/ui/components/card" +import { NumericText } from "@workspace/ui/components/numeric" +import { Text } from "@workspace/ui/components/text" +import { getNextTier, getTierByLevel } from "../../data/tiers" +import { TierBadge } from "./tier-badge" import { formatUsd } from "@/shared/lib/format" type Props = { @@ -13,22 +17,12 @@ export function TierProgress({ tier, volumeUsd }: Props) { if (!next) { return ( -
- - {current.label} - - Maximum tier reached! -
+ + + }> + Maximum tier reached! + + ) } @@ -36,36 +30,22 @@ export function TierProgress({ tier, volumeUsd }: Props) { const remaining = Math.max(next.minVolumeUsd - volumeUsd, 0) return ( -
-
+
- - {current.label} - - - - {next.label} - + + }> + → + +
- + {formatUsd(remaining, { compact: true })} more needed - +
-
+ ) } diff --git a/apps/web/src/features/referrals/components/shared/time-period-filter.tsx b/apps/web/src/features/referrals/components/shared/time-period-filter.tsx index d93af45..799b049 100644 --- a/apps/web/src/features/referrals/components/shared/time-period-filter.tsx +++ b/apps/web/src/features/referrals/components/shared/time-period-filter.tsx @@ -1,3 +1,4 @@ +import { Button } from "@workspace/ui/components/button" import { cn } from "@workspace/ui/lib/utils" import type { TimePeriod } from "../../hooks/use-referrals-data" @@ -16,20 +17,26 @@ type Props = { export function TimePeriodFilter({ value, onChange }: Props) { return ( -
+
{PERIODS.map((p) => ( - + ))}
) diff --git a/apps/web/src/features/referrals/components/traders/traders-tab.tsx b/apps/web/src/features/referrals/components/traders/traders-tab.tsx index 2a69cfb..03af684 100644 --- a/apps/web/src/features/referrals/components/traders/traders-tab.tsx +++ b/apps/web/src/features/referrals/components/traders/traders-tab.tsx @@ -1,6 +1,21 @@ import { useState } from "react" -import { Button } from "@workspace/ui/components/button" +import { Alert, AlertDescription, AlertTitle } from "@workspace/ui/components/alert" +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card" +import { Input } from "@workspace/ui/components/input" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" import { Skeleton } from "@workspace/ui/components/skeleton" +import { Stat } from "@workspace/ui/components/stat" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Heading, Text } from "@workspace/ui/components/text" import { useDistributions, useTraderStats } from "../../hooks/use-referrals-data" import { useReferralStats } from "../../queries/useReferralStats" import { @@ -10,11 +25,12 @@ import { } from "../../lib/referrals" import { TimePeriodFilter } from "../shared/time-period-filter" import { StatChartCard } from "../shared/stat-chart-card" +import { TierBadge } from "../shared/tier-badge" +import { TIERS } from "../../data/tiers" import type { TimePeriod } from "../../hooks/use-referrals-data" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { formatUsd } from "@/shared/lib/format" - function fmtDate(iso: string) { return new Intl.DateTimeFormat("en-US", { year: "numeric", @@ -27,6 +43,12 @@ function fmtDate(iso: string) { }).format(new Date(iso)) } +const DISCOUNT_TIER_VOLUME: Record = { + Bronze: "Any volume", + Silver: "$2.5K+ / mo", + Gold: "$25K+ / mo", +} + type JoinCodeFormProps = { onSuccess: () => void } @@ -61,75 +83,87 @@ function JoinCodeForm({ onSuccess }: JoinCodeFormProps) { } return ( -
-
-
- - - - -
-
-

Enter a referral code to receive a fee discount

-

- Get up to 5% off every open and - close fee. Rewards scale with the affiliate's tier. -

-
-
+ + + + + + + + + +
+ Enter a referral code to receive a fee discount + + Get up to 5% off every open and + close fee. Rewards scale with the affiliate's tier. + +
+
-
-
- -
- { - setCode(e.target.value.toUpperCase()) - setError(null) - }} - placeholder="e.g. MYCODE123" - autoComplete="off" - spellCheck={false} - className="flex h-9 w-full rounded-lg border border-border bg-muted/30 px-3 font-mono text-[13px] tracking-widest placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30" - /> - + Referral code + +
+ { + setCode(e.target.value.toUpperCase()) + setError(null) + }} + placeholder="e.g. MYCODE123" + autoComplete="off" + spellCheck={false} + aria-invalid={error ? true : undefined} + className="h-9 font-mono tracking-widest placeholder:font-sans placeholder:tracking-normal" + /> + + Apply + +
+ {error && ( + + {error} + + )}
- {error &&

{error}

} -
-
+ -
-

- Discount tiers -

-
- {[ - { label: "Bronze", pct: 5, vol: "Any volume", color: "text-orange-400 bg-orange-500/10 ring-orange-500/20" }, - { label: "Silver", pct: 5, vol: "$2.5K+ / mo", color: "text-slate-300 bg-slate-500/10 ring-slate-400/20" }, - { label: "Gold", pct: 5, vol: "$25K+ / mo", color: "text-yellow-400 bg-yellow-500/10 ring-yellow-400/20" }, - ].map((tier) => ( -
- - {tier.label} - -

{tier.pct}%

-

{tier.vol}

-
- ))} +
+ + Discount tiers + +
+ {TIERS.map((tier) => ( + + + + {tier.traderDiscountPct}% + + + {DISCOUNT_TIER_VOLUME[tier.label] ?? "Any volume"} + + + ))} +
-
-
+
+
) } @@ -156,13 +190,13 @@ function Overview({ return (
-
-

Overview

+
+ Overview
{isLoading ? ( -
+
@@ -186,22 +220,31 @@ function Overview({ )} {claimable > 0 && ( -
-
-

Claimable rebates

-

{formatUsd(claimable)}

-
- -
+ + + + Claim rebates + + )} {stats?.lastUpdated && ( -

+ Last updated:{" "} - {fmtDate(stats.lastUpdated)} -

+ + {fmtDate(stats.lastUpdated)} + + )}
) @@ -219,31 +262,33 @@ function DistributionsHistory() { } return ( -
-
-

Rebate history

-
-
- - - - - - - - - - {distributions.map((d) => ( - - - - - - ))} - -
EpochDateAmount
{d.epoch}{d.date}{formatUsd(d.amountUsd)}
-
-
+ + + Rebate history + + + + + Epoch + Date + Amount + + + + {distributions.map((d) => ( + + + {d.epoch} + + {d.date} + + {formatUsd(d.amountUsd)} + + + ))} + +
+
) } diff --git a/apps/web/src/features/referrals/data/tiers.ts b/apps/web/src/features/referrals/data/tiers.ts index 19bb722..9fc6aba 100644 --- a/apps/web/src/features/referrals/data/tiers.ts +++ b/apps/web/src/features/referrals/data/tiers.ts @@ -5,8 +5,12 @@ export type Tier = { minVolumeUsd: number traderDiscountPct: number affiliateCommissionPct: number - colorClass: string - ringClass: string + /** + * Decorative metallic tint layered on the shared `Badge` primitive. Tier + * identity is brand colour, not a status role, so it stays data-driven here + * instead of becoming a semantic token. + */ + badgeClass: string } export const TIERS: Array = [ @@ -16,8 +20,7 @@ export const TIERS: Array = [ minVolumeUsd: 0, traderDiscountPct: 5, affiliateCommissionPct: 5, - colorClass: "text-orange-400 bg-orange-500/10", - ringClass: "ring-orange-500/30", + badgeClass: "border-orange-500/30 bg-orange-500/10 text-orange-400", }, { level: 2, @@ -25,8 +28,7 @@ export const TIERS: Array = [ minVolumeUsd: 2_500, traderDiscountPct: 5, affiliateCommissionPct: 10, - colorClass: "text-slate-300 bg-slate-500/10", - ringClass: "ring-slate-400/30", + badgeClass: "border-slate-400/30 bg-slate-500/10 text-slate-300", }, { level: 3, @@ -34,8 +36,7 @@ export const TIERS: Array = [ minVolumeUsd: 25_000, traderDiscountPct: 5, affiliateCommissionPct: 15, - colorClass: "text-yellow-400 bg-yellow-500/10", - ringClass: "ring-yellow-400/30", + badgeClass: "border-yellow-400/30 bg-yellow-500/10 text-yellow-400", }, ] diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index bd8b25b..bdb6c7e 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -1,76 +1,18 @@ -import { - - CandlestickSeries, - ColorType, - CrosshairMode, - - - - LineStyle, - - createChart -} from "lightweight-charts" -import { useEffect, useRef } from "react" +import { CandlestickSeries, LineStyle, createChart } from "lightweight-charts" +import { useEffect, useRef, useState } from "react" import { Skeleton } from "@workspace/ui/components/skeleton" import { useOracleCandles } from "../../hooks/useOracleCandles" import { useLiveBar } from "../../hooks/useLiveBar" import { usePositions } from "../../hooks/usePositions" +import { + buildCandleOptions, + buildChartOptions, + getChartPalette, + positionLineColor, +} from "../../lib/chart-theme" import type {CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp} from "lightweight-charts"; import type { OhlcBar } from "../../lib/oracle" -const CHART_COLORS = { - dark: { - background: "#0d0e1a", - text: "#9598a1", - grid: "#1e2035", - crosshair: "#444860", - crosshairLabel: "#2a2e3e", - border: "#1e2035", - }, - light: { - background: "#ffffff", - text: "#4b5563", - grid: "#e5e7eb", - crosshair: "#9ca3af", - crosshairLabel: "#f3f4f6", - border: "#e5e7eb", - }, -} - -function isDarkMode() { - return document.documentElement.classList.contains("dark") -} - -function buildChartOptions(isDark: boolean) { - const c = isDark ? CHART_COLORS.dark : CHART_COLORS.light - return { - layout: { - background: { type: ColorType.Solid, color: c.background }, - textColor: c.text, - fontSize: 11, - }, - grid: { - vertLines: { color: c.grid, style: LineStyle.Solid }, - horzLines: { color: c.grid, style: LineStyle.Solid }, - }, - crosshair: { - mode: CrosshairMode.Normal, - vertLine: { color: c.crosshair, labelBackgroundColor: c.crosshairLabel }, - horzLine: { color: c.crosshair, labelBackgroundColor: c.crosshairLabel }, - }, - rightPriceScale: { - borderColor: c.border, - scaleMargins: { top: 0.1, bottom: 0.1 }, - }, - timeScale: { - borderColor: c.border, - timeVisible: true, - secondsVisible: false, - rightOffset: 5, - }, - } -} - type ChartLine = { id: string title: string @@ -107,23 +49,22 @@ export function TVChartContainer({ symbol, period }: Props) { const liveBar = useLiveBar(symbol, period) const { data: positions = [] } = usePositions() + // Bumped by the theme observer below so colour-dependent effects re-run. + const [themeVersion, setThemeVersion] = useState(0) + // ── Mount chart once ─────────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return + const palette = getChartPalette() + const chart = createChart(containerRef.current, { - ...buildChartOptions(isDarkMode()), + ...buildChartOptions(palette), handleScroll: { mouseWheel: true, pressedMouseMove: true }, handleScale: { mouseWheel: true, pinch: true }, }) - const series = chart.addSeries(CandlestickSeries, { - upColor: "#26a69a", - downColor: "#ef5350", - borderVisible: false, - wickUpColor: "#26a69a", - wickDownColor: "#ef5350", - }) + const series = chart.addSeries(CandlestickSeries, buildCandleOptions(palette)) chartRef.current = chart seriesRef.current = series @@ -139,9 +80,14 @@ export function TVChartContainer({ symbol, period }: Props) { }) resizeObserver.observe(containerRef.current) - // Watch and re-theme the chart immediately + // Watch and re-theme the chart immediately — + // no reload needed. Bumping themeVersion re-colours the series and the + // position lines through the effects below. const themeObserver = new MutationObserver(() => { - chart.applyOptions(buildChartOptions(isDarkMode())) + const next = getChartPalette() + chart.applyOptions(buildChartOptions(next)) + series.applyOptions(buildCandleOptions(next)) + setThemeVersion((v) => v + 1) }) themeObserver.observe(document.documentElement, { attributes: true, @@ -194,6 +140,8 @@ export function TVChartContainer({ symbol, period }: Props) { useEffect(() => { if (!seriesRef.current) return + const palette = getChartPalette() + // Build the desired set of lines from open positions const desiredLines: Array = positions .filter((p) => p.indexToken === symbol) @@ -203,7 +151,7 @@ export function TVChartContainer({ symbol, period }: Props) { id: `${p.key}-entry`, title: `${p.isLong ? "Long" : "Short"} Entry`, price: p.entryPrice, - color: p.isLong ? "#26a69a" : "#ef5350", + color: positionLineColor(palette, p.isLong), lineStyle: LineStyle.Dashed, }, ] @@ -212,7 +160,7 @@ export function TVChartContainer({ symbol, period }: Props) { id: `${p.key}-liq`, title: `${p.isLong ? "Long" : "Short"} Liq.`, price: p.liquidationPrice, - color: "#f59e0b", + color: palette.liquidation, lineStyle: LineStyle.LargeDashed, }) } @@ -229,21 +177,25 @@ export function TVChartContainer({ symbol, period }: Props) { } }) - // Add new lines (skip duplicates) + // Add new lines, and re-colour the ones already drawn so a theme switch + // repaints them in place. desiredLines.forEach((line) => { - if (!priceLineRefs.current.has(line.id)) { - const priceLine = seriesRef.current!.createPriceLine({ - price: line.price, - color: line.color, - lineWidth: 1, - lineStyle: line.lineStyle ?? LineStyle.Dashed, - axisLabelVisible: true, - title: line.title, - }) - priceLineRefs.current.set(line.id, priceLine) + const existing = priceLineRefs.current.get(line.id) + if (existing) { + existing.applyOptions({ color: line.color }) + return } + const priceLine = seriesRef.current!.createPriceLine({ + price: line.price, + color: line.color, + lineWidth: 1, + lineStyle: line.lineStyle ?? LineStyle.Dashed, + axisLabelVisible: true, + title: line.title, + }) + priceLineRefs.current.set(line.id, priceLine) }) - }, [positions, symbol]) + }, [positions, symbol, themeVersion]) return (
diff --git a/apps/web/src/features/trade/lib/chart-theme.test.ts b/apps/web/src/features/trade/lib/chart-theme.test.ts new file mode 100644 index 0000000..3a0bbe4 --- /dev/null +++ b/apps/web/src/features/trade/lib/chart-theme.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from "vitest" +import { + CHART_PALETTE_FALLBACKS, + CHART_TOKENS, + buildCandleOptions, + buildChartOptions, + buildChartPalette, + positionLineColor, + resolveChartTheme, + toChartColor, +} from "./chart-theme" +import type { ChartPalette, ChartTheme, TokenResolver } from "./chart-theme" + +/** + * Stand-in for `getComputedStyle(document.documentElement)` after the resolver + * has converted each `oklch()` token into a chart-safe colour — the values the + * browser produces for the tokens declared in `@workspace/ui/globals.css`. + */ +const TOKEN_VALUES: Record> = { + light: { + "--chart-surface": "rgb(255, 255, 255)", + "--chart-text": "rgb(115, 115, 115)", + "--chart-grid": "rgb(229, 229, 229)", + "--chart-crosshair": "rgb(161, 161, 161)", + "--chart-crosshair-label": "rgb(240, 240, 240)", + "--chart-border": "rgb(229, 229, 229)", + "--chart-up": "rgb(0, 153, 102)", + "--chart-down": "rgb(231, 0, 11)", + "--long": "rgb(0, 153, 102)", + "--short": "rgb(231, 0, 11)", + "--chart-liquidation": "rgb(208, 135, 0)", + }, + dark: { + "--chart-surface": "rgb(10, 10, 10)", + "--chart-text": "rgb(161, 161, 161)", + "--chart-grid": "rgb(38, 38, 38)", + "--chart-crosshair": "rgb(82, 82, 82)", + "--chart-crosshair-label": "rgb(38, 38, 38)", + "--chart-border": "rgb(38, 38, 38)", + "--chart-up": "rgb(0, 221, 142)", + "--chart-down": "rgb(255, 100, 103)", + "--long": "rgb(0, 221, 142)", + "--short": "rgb(255, 100, 103)", + "--chart-liquidation": "rgb(249, 187, 46)", + }, +} + +function resolverFor(theme: ChartTheme): TokenResolver { + return (token) => TOKEN_VALUES[theme][token] ?? "" +} + +/** Colour notations `lightweight-charts` can paint without conversion. */ +const CHART_SAFE = /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i + +const ROLES = Object.keys(CHART_TOKENS) as Array +const THEMES: Array = ["light", "dark"] + +describe("chart-theme — token map", () => { + it("maps every palette role to a CSS custom property", () => { + expect(ROLES).toHaveLength(11) + for (const role of ROLES) { + expect(CHART_TOKENS[role].startsWith("--")).toBe(true) + } + }) + + it("declares a fallback for every role in both themes", () => { + for (const theme of THEMES) { + for (const role of ROLES) { + expect(CHART_PALETTE_FALLBACKS[theme][role]).toBeTruthy() + } + } + }) + + it("keeps every fallback in a notation lightweight-charts can parse", () => { + for (const theme of THEMES) { + for (const role of ROLES) { + expect(CHART_PALETTE_FALLBACKS[theme][role]).toMatch(CHART_SAFE) + } + } + }) +}) + +describe("chart-theme — toChartColor", () => { + it("passes through notations the chart already understands", () => { + const convert = vi.fn(() => "rgb(0, 0, 0)") + for (const value of ["#0a0a0a", "rgb(1, 2, 3)", "rgba(1, 2, 3, 0.5)", "hsl(0 0% 0%)"]) { + expect(toChartColor(value, convert)).toBe(value) + } + expect(convert).not.toHaveBeenCalled() + }) + + it("converts colour notations the chart cannot parse", () => { + const convert = vi.fn(() => "rgb(0, 153, 102)") + expect(toChartColor("oklch(0.596 0.145 163.225)", convert)).toBe("rgb(0, 153, 102)") + expect(convert).toHaveBeenCalledWith("oklch(0.596 0.145 163.225)") + }) + + it("returns an empty string for blank values without converting", () => { + const convert = vi.fn(() => "rgb(0, 0, 0)") + expect(toChartColor(" ", convert)).toBe("") + expect(convert).not.toHaveBeenCalled() + }) + + it("returns an empty string when conversion fails, so the fallback wins", () => { + expect(toChartColor("oklch(0.5 0 0)", () => "")).toBe("") + }) +}) + +describe.each(THEMES)("chart-theme — %s palette", (theme) => { + const palette = buildChartPalette(theme, resolverFor(theme)) + + it("reads every role from its CSS token", () => { + for (const role of ROLES) { + expect(palette[role]).toBe(TOKEN_VALUES[theme][CHART_TOKENS[role]]) + } + }) + + it("resolves every role to a colour the chart can paint", () => { + for (const role of ROLES) { + expect(palette[role]).not.toBe("") + expect(palette[role]).toMatch(CHART_SAFE) + } + }) + + it("uses the same colour for candles and position lines", () => { + expect(palette.up).toBe(palette.long) + expect(palette.down).toBe(palette.short) + }) + + it("distinguishes long, short and liquidation colours", () => { + expect(new Set([palette.long, palette.short, palette.liquidation]).size).toBe(3) + }) + + it("falls back to the literal token values when nothing resolves", () => { + expect(buildChartPalette(theme)).toEqual(CHART_PALETTE_FALLBACKS[theme]) + }) + + it("falls back per-role when a single token is missing", () => { + const partial = buildChartPalette(theme, (token) => + token === CHART_TOKENS.grid ? "" : (TOKEN_VALUES[theme][token] ?? ""), + ) + expect(partial.grid).toBe(CHART_PALETTE_FALLBACKS[theme].grid) + expect(partial.background).toBe(TOKEN_VALUES[theme]["--chart-surface"]) + }) + + it("falls back when the resolver throws", () => { + const thrown = buildChartPalette(theme, () => { + throw new Error("detached node") + }) + expect(thrown).toEqual(CHART_PALETTE_FALLBACKS[theme]) + }) + + it("builds chart options from the palette", () => { + const options = buildChartOptions(palette) + + expect(options.layout?.background).toMatchObject({ color: palette.background }) + expect(options.layout?.textColor).toBe(palette.text) + expect(options.grid?.vertLines?.color).toBe(palette.grid) + expect(options.grid?.horzLines?.color).toBe(palette.grid) + expect(options.crosshair?.vertLine?.color).toBe(palette.crosshair) + expect(options.crosshair?.horzLine?.color).toBe(palette.crosshair) + expect(options.crosshair?.vertLine?.labelBackgroundColor).toBe(palette.crosshairLabel) + expect(options.crosshair?.horzLine?.labelBackgroundColor).toBe(palette.crosshairLabel) + expect(options.rightPriceScale?.borderColor).toBe(palette.border) + expect(options.timeScale?.borderColor).toBe(palette.border) + }) + + it("builds candle options from the palette", () => { + expect(buildCandleOptions(palette)).toMatchObject({ + upColor: palette.up, + downColor: palette.down, + wickUpColor: palette.up, + wickDownColor: palette.down, + borderVisible: false, + }) + }) + + it("colours position lines by side", () => { + expect(positionLineColor(palette, true)).toBe(palette.long) + expect(positionLineColor(palette, false)).toBe(palette.short) + }) +}) + +describe("chart-theme — light vs dark", () => { + const light = buildChartPalette("light", resolverFor("light")) + const dark = buildChartPalette("dark", resolverFor("dark")) + + it("generates a distinct palette per theme", () => { + for (const role of ROLES) { + expect(light[role]).not.toBe(dark[role]) + } + }) + + it("produces chart options that differ between themes", () => { + expect(buildChartOptions(light)).not.toEqual(buildChartOptions(dark)) + expect(buildCandleOptions(light)).not.toEqual(buildCandleOptions(dark)) + }) +}) + +describe("chart-theme — resolveChartTheme", () => { + it("reports dark when the root carries the dark class", () => { + const root = document.createElement("html") + root.classList.add("dark") + expect(resolveChartTheme(root)).toBe("dark") + }) + + it("reports light for the light class", () => { + const root = document.createElement("html") + root.classList.add("light") + expect(resolveChartTheme(root)).toBe("light") + }) + + it("defaults to light when no root is available", () => { + expect(resolveChartTheme(null)).toBe("light") + }) +}) diff --git a/apps/web/src/features/trade/lib/chart-theme.ts b/apps/web/src/features/trade/lib/chart-theme.ts new file mode 100644 index 0000000..a7edd10 --- /dev/null +++ b/apps/web/src/features/trade/lib/chart-theme.ts @@ -0,0 +1,259 @@ +import { ColorType, CrosshairMode, LineStyle } from "lightweight-charts" +import type { CandlestickSeriesOptions, ChartOptions, DeepPartial } from "lightweight-charts" + +/** + * Chart theming adapter. + * + * `lightweight-charts` paints to a canvas, so it cannot consume Tailwind + * classes — it needs concrete colour strings. This module is the single bridge + * between the CSS semantic tokens declared in `@workspace/ui/globals.css` and + * the chart: every colour the chart draws is read from a `--chart-*` custom + * property, which in turn aliases a semantic token (`--long`, `--short`, + * `--warning`, `--border`, …). Change a token and the chart follows. + */ + +export type ChartTheme = "light" | "dark" + +/** Every colour role the trading chart can paint. */ +export type ChartPalette = { + /** Canvas background. */ + background: string + /** Axis and legend text. */ + text: string + /** Horizontal + vertical grid lines. */ + grid: string + /** Crosshair rules. */ + crosshair: string + /** Fill behind the crosshair's axis labels. */ + crosshairLabel: string + /** Price/time scale borders. */ + border: string + /** Rising candle body + wick. */ + up: string + /** Falling candle body + wick. */ + down: string + /** Long position entry line — matches "long" everywhere else in the app. */ + long: string + /** Short position entry line. */ + short: string + /** Liquidation price line. */ + liquidation: string +} + +export type ChartPaletteRole = keyof ChartPalette + +/** CSS custom property backing each palette role. */ +export const CHART_TOKENS = { + background: "--chart-surface", + text: "--chart-text", + grid: "--chart-grid", + crosshair: "--chart-crosshair", + crosshairLabel: "--chart-crosshair-label", + border: "--chart-border", + up: "--chart-up", + down: "--chart-down", + long: "--long", + short: "--short", + liquidation: "--chart-liquidation", +} as const satisfies Record + +/** + * sRGB mirrors of the token values in `globals.css`. + * + * Used when no computed style is available — server rendering, unit tests, or + * the first paint before the stylesheet has applied. They are hex rather than + * `oklch()` because `lightweight-charts` parses colours itself and only + * understands hex / rgb / hsl / named colours. + */ +export const CHART_PALETTE_FALLBACKS: Record = { + light: { + background: "#ffffff", + text: "#737373", + grid: "#e5e5e5", + crosshair: "#a1a1a1", + crosshairLabel: "#f0f0f0", + border: "#e5e5e5", + up: "#009966", + down: "#e7000b", + long: "#009966", + short: "#e7000b", + liquidation: "#d08700", + }, + dark: { + background: "#0a0a0a", + text: "#a1a1a1", + grid: "#262626", + crosshair: "#525252", + crosshairLabel: "#262626", + border: "#262626", + up: "#00dd8e", + down: "#ff6467", + long: "#00dd8e", + short: "#ff6467", + liquidation: "#f9bb2e", + }, +} + +/** Resolves a CSS custom property name to a chart-safe colour string. */ +export type TokenResolver = (token: string) => string + +/** Converts an arbitrary CSS colour into a chart-safe string, or "" on failure. */ +export type ColorConverter = (value: string) => string + +/** Reads the active theme off the `dark`/`light` class the ThemeProvider sets. */ +export function resolveChartTheme(root: Element | null = getRoot()): ChartTheme { + return root?.classList.contains("dark") ? "dark" : "light" +} + +/** Colour notations `lightweight-charts` can parse without conversion. */ +const CHART_SAFE_COLOR = /^(#|rgba?\(|hsla?\(|transparent$|[a-z]+$)/i + +let sharedConverter: ColorConverter | null = null + +/** + * Tokens are authored in `oklch()`, which the chart library cannot parse. + * Painting the colour onto a 1×1 canvas and reading the pixel back converts any + * CSS colour the browser understands into plain `rgb()` / `rgba()`. + */ +export function canvasColorConverter(): ColorConverter { + if (sharedConverter) return sharedConverter + if (typeof document === "undefined") return () => "" + + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext("2d", { willReadFrequently: true }) + if (!ctx) return () => "" + + sharedConverter = (value) => { + try { + ctx.clearRect(0, 0, 1, 1) + ctx.fillStyle = "#000000" + ctx.fillStyle = value + // An unparseable colour leaves fillStyle untouched. + if (ctx.fillStyle === "#000000" && !/^#0{3,8}$/i.test(value)) return "" + ctx.fillRect(0, 0, 1, 1) + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + return a === 255 + ? `rgb(${r}, ${g}, ${b})` + : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})` + } catch { + return "" + } + } + + return sharedConverter +} + +/** Normalises `value` only when the chart could not parse it as-is. */ +export function toChartColor(value: string, convert: ColorConverter): string { + const trimmed = value.trim() + if (!trimmed) return "" + if (CHART_SAFE_COLOR.test(trimmed)) return trimmed + return convert(trimmed) +} + +/** + * Builds a resolver backed by the element's computed style, converting each + * token value into something the chart can paint. + */ +export function cssTokenResolver( + root: Element | null = getRoot(), + convert: ColorConverter = canvasColorConverter(), +): TokenResolver { + if (!root || typeof window === "undefined") return () => "" + const computed = window.getComputedStyle(root) + return (token) => toChartColor(computed.getPropertyValue(token), convert) +} + +/** + * Builds the palette for `theme`, taking each role from its CSS token and + * falling back to the literal value when the token is unset or unreadable. + */ +export function buildChartPalette( + theme: ChartTheme, + resolve: TokenResolver = () => "", +): ChartPalette { + const fallback = CHART_PALETTE_FALLBACKS[theme] + const roles = Object.keys(CHART_TOKENS) as Array + + return roles.reduce((palette, role) => { + let value = "" + try { + value = resolve(CHART_TOKENS[role]) + } catch { + // A resolver backed by a detached node can throw — fall back silently. + value = "" + } + palette[role] = value || fallback[role] + return palette + }, {} as ChartPalette) +} + +/** Reads the palette for whichever theme is currently applied to the document. */ +export function getChartPalette(root: Element | null = getRoot()): ChartPalette { + return buildChartPalette(resolveChartTheme(root), cssTokenResolver(root)) +} + +/** Chart-level options (layout, grid, crosshair, scales) for a palette. */ +export function buildChartOptions( + palette: ChartPalette, +): DeepPartial { + return { + layout: { + background: { type: ColorType.Solid, color: palette.background }, + textColor: palette.text, + fontSize: 11, + }, + grid: { + vertLines: { color: palette.grid, style: LineStyle.Solid }, + horzLines: { color: palette.grid, style: LineStyle.Solid }, + }, + crosshair: { + mode: CrosshairMode.Normal, + vertLine: { + color: palette.crosshair, + labelBackgroundColor: palette.crosshairLabel, + }, + horzLine: { + color: palette.crosshair, + labelBackgroundColor: palette.crosshairLabel, + }, + }, + rightPriceScale: { + borderColor: palette.border, + scaleMargins: { top: 0.1, bottom: 0.1 }, + }, + timeScale: { + borderColor: palette.border, + timeVisible: true, + secondsVisible: false, + rightOffset: 5, + }, + } +} + +/** Candlestick series colours for a palette. */ +export function buildCandleOptions( + palette: ChartPalette, +): DeepPartial { + return { + upColor: palette.up, + downColor: palette.down, + borderVisible: false, + wickUpColor: palette.up, + wickDownColor: palette.down, + } +} + +/** Colour for a position line, keyed by side. */ +export function positionLineColor( + palette: ChartPalette, + isLong: boolean, +): string { + return isLong ? palette.long : palette.short +} + +function getRoot(): Element | null { + return typeof document === "undefined" ? null : document.documentElement +} diff --git a/packages/ui/README.md b/packages/ui/README.md index ff0bc68..b70ab50 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -20,6 +20,23 @@ All components are located in `src/components/` and exported through the package - **TokenAvatar** - Token icon with fallback initials - **TokenPair** - Overlapping token pair visual - **TransactionStatus** - Transaction state indicator with actions +- **Text / Heading** - Shared type scale and tones; use instead of one-off font sizes +- **NumericText** - Tabular figures with semantic roles (`positive`, `negative`, `warning`, `accent`, `muted`) +- **Stat** - Label + numeric value pair with a built-in loading state +- **Card** - Surface primitive (`default`, `muted`, `dashed`, `plain`) with header/content/footer slots +- **Table** - Data-table primitives that scroll horizontally instead of widening the page +- **Alert** - Status messaging (`info`, `success`, `warning`, `danger`, `muted`) with the right ARIA role +- **LoadingState / EmptyState / ErrorState** - Shared list and table state treatments +- **Spinner** - Busy indicator, hidden from assistive tech unless labelled +- **LoadingButton** - Button that owns its pending state, spinner and `aria-busy` + +## Semantic tokens + +`src/styles/globals.css` defines the semantic colour roles every component draws +from: `--success`, `--warning`, `--info`, `--destructive`, plus the trading +aliases `--long` / `--short` and the `--chart-*` roles the trading chart reads +through its typed adapter. Prefer these over palette classes such as +`text-green-400` so light and dark themes stay in sync. ## Testing diff --git a/packages/ui/axe-matchers.d.ts b/packages/ui/axe-matchers.d.ts new file mode 100644 index 0000000..66e8c67 --- /dev/null +++ b/packages/ui/axe-matchers.d.ts @@ -0,0 +1,16 @@ +import "vitest" + +/** + * vitest-axe@0.1.0 augments the legacy global `Vi` namespace, which Vitest 3 no + * longer reads. Re-declare the matcher against the modern `vitest` module so + * `expect(results).toHaveNoViolations()` type-checks. The matcher itself is + * registered in ./vitest.setup.ts. + */ +declare module "vitest" { + interface Assertion { + toHaveNoViolations: () => void + } + interface AsymmetricMatchersContaining { + toHaveNoViolations: () => void + } +} diff --git a/packages/ui/setup-tests.ts b/packages/ui/setup-tests.ts deleted file mode 100644 index 7604e66..0000000 --- a/packages/ui/setup-tests.ts +++ /dev/null @@ -1,7 +0,0 @@ -import "@testing-library/jest-dom/vitest" -import { afterEach } from "vitest" -import { cleanup } from "@testing-library/react" - -afterEach(() => { - cleanup() -}) diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx new file mode 100644 index 0000000..57a8b65 --- /dev/null +++ b/packages/ui/src/components/alert.tsx @@ -0,0 +1,56 @@ +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +const alertVariants = cva( + "flex w-full items-start gap-3 rounded-lg border px-4 py-3 [&>svg]:mt-px [&>svg]:shrink-0", + { + variants: { + variant: { + info: "border-info/20 bg-info/[0.07] text-info", + success: "border-success/20 bg-success/[0.07] text-success", + warning: "border-warning/30 bg-warning/10 text-warning", + danger: "border-destructive/30 bg-destructive/10 text-destructive", + muted: "border-border bg-muted/30 text-muted-foreground", + }, + }, + defaultVariants: { variant: "info" }, + } +) + +type AlertProps = React.ComponentProps<"div"> & + VariantProps + +function Alert({ className, variant = "info", role, ...props }: AlertProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { Alert, AlertTitle, AlertDescription, alertVariants } diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index c958e43..b1b89a7 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -15,6 +15,12 @@ const badgeVariants = cva( "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + success: + "border-success/20 bg-success/10 text-success [a]:hover:bg-success/20", + warning: + "border-warning/20 bg-warning/10 text-warning [a]:hover:bg-warning/20", + info: "border-info/20 bg-info/10 text-info [a]:hover:bg-info/20", + muted: "border-border bg-muted/60 text-muted-foreground", outline: "border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground", ghost: @@ -50,4 +56,7 @@ function Badge({ }) } +type BadgeVariant = NonNullable["variant"]> + export { Badge, badgeVariants } +export type { BadgeVariant } diff --git a/packages/ui/src/components/button.test.tsx b/packages/ui/src/components/button.test.tsx index ebf096b..b205787 100644 --- a/packages/ui/src/components/button.test.tsx +++ b/packages/ui/src/components/button.test.tsx @@ -18,7 +18,7 @@ describe("Button accessibility", () => { it("with icon has proper sizing", async () => { const { container } = render( - ) diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx new file mode 100644 index 0000000..ae32a7a --- /dev/null +++ b/packages/ui/src/components/card.tsx @@ -0,0 +1,70 @@ +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +const cardVariants = cva("rounded-xl border", { + variants: { + variant: { + /** Default raised surface — cards, panels, sidebars. */ + default: "border-border bg-card", + /** Recessed surface for supporting information. */ + muted: "border-border bg-muted/20", + /** Placeholder surface for "nothing here yet". */ + dashed: "border-dashed border-border bg-muted/10", + /** Frame only — used when the body is a table that paints its own rows. */ + plain: "border-border bg-transparent overflow-hidden", + }, + padding: { + none: "", + sm: "p-4", + md: "p-5", + lg: "p-6", + }, + }, + defaultVariants: { variant: "default", padding: "none" }, +}) + +function Card({ + className, + variant, + padding, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +

+ ) +} + +/** Header strip with a bottom rule — the standard table/card caption row. */ +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardContent, CardFooter, cardVariants } diff --git a/packages/ui/src/components/input.test.tsx b/packages/ui/src/components/input.test.tsx index d553166..0d5996e 100644 --- a/packages/ui/src/components/input.test.tsx +++ b/packages/ui/src/components/input.test.tsx @@ -35,7 +35,7 @@ describe("Input accessibility", () => { }) it("supports invalid state", async () => { - const { container } = render() + const { container } = render() const input = screen.getByRole("textbox") expect(input).toHaveAttribute("aria-invalid", "true") diff --git a/packages/ui/src/components/loading-button.tsx b/packages/ui/src/components/loading-button.tsx new file mode 100644 index 0000000..633e2a7 --- /dev/null +++ b/packages/ui/src/components/loading-button.tsx @@ -0,0 +1,45 @@ +import { Button } from "@workspace/ui/components/button" +import { Spinner } from "@workspace/ui/components/spinner" +import { cn } from "@workspace/ui/lib/utils" +import type { ComponentProps } from "react" + +type LoadingButtonProps = ComponentProps & { + isLoading?: boolean + /** Label shown while `isLoading`. Defaults to the button's children. */ + loadingText?: React.ReactNode +} + +/** + * Button that owns its own pending presentation: disables itself, flags + * `aria-busy` and swaps in a spinner. The spinner is `aria-hidden`, so the + * accessible name is exactly `loadingText` (or the children). + */ +function LoadingButton({ + isLoading = false, + loadingText, + disabled, + className, + children, + ...props +}: LoadingButtonProps) { + return ( + + ) +} + +export { LoadingButton } diff --git a/packages/ui/src/components/numeric.tsx b/packages/ui/src/components/numeric.tsx new file mode 100644 index 0000000..214de58 --- /dev/null +++ b/packages/ui/src/components/numeric.tsx @@ -0,0 +1,81 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +/** + * Semantic roles for financial figures. + * + * - `neutral` — a plain amount (balance, TVL, volume) + * - `muted` — a de-emphasised amount (secondary column, placeholder) + * - `positive` — yield, APR, rewards, gains, long side + * - `negative` — losses, short side + * - `warning` — at-risk values (liquidation price, cooldown) + * - `accent` — brand-highlighted figures (commissions, tier rates) + */ +export type NumericRole = + | "neutral" + | "muted" + | "positive" + | "negative" + | "warning" + | "accent" + +const numericVariants = cva("font-mono tabular-nums", { + variants: { + role: { + neutral: "text-foreground", + muted: "text-muted-foreground", + positive: "text-success", + negative: "text-destructive", + warning: "text-warning", + accent: "text-primary", + }, + size: { + "2xs": "text-[0.625rem] leading-4", + xs: "text-[0.6875rem] leading-4", + sm: "text-xs leading-5", + md: "text-[0.8125rem] leading-5", + base: "text-sm leading-5", + lg: "text-base leading-6", + xl: "text-[1.375rem] leading-7 tracking-tight", + }, + weight: { + normal: "font-normal", + medium: "font-medium", + semibold: "font-semibold", + bold: "font-bold", + }, + }, + defaultVariants: { role: "neutral", size: "sm", weight: "normal" }, +}) + +function NumericText({ + className, + role, + size, + weight, + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { className: cn(numericVariants({ role, size, weight }), className) }, + props + ), + render, + state: { slot: "numeric", role }, + }) +} + +/** Maps a signed value onto the matching numeric role. */ +function numericRoleForValue(value: number): NumericRole { + if (value > 0) return "positive" + if (value < 0) return "negative" + return "neutral" +} + +export { NumericText, numericRoleForValue, numericVariants } diff --git a/packages/ui/src/components/primitives.test.tsx b/packages/ui/src/components/primitives.test.tsx new file mode 100644 index 0000000..f14029f --- /dev/null +++ b/packages/ui/src/components/primitives.test.tsx @@ -0,0 +1,311 @@ +import { describe, expect, it, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { axe } from "vitest-axe" +import { Alert, AlertDescription, AlertTitle } from "./alert" +import { Card, CardContent, CardHeader } from "./card" +import { LoadingButton } from "./loading-button" +import { NumericText, numericRoleForValue } from "./numeric" +import { Spinner } from "./spinner" +import { Stat } from "./stat" +import { EmptyState, ErrorState, LoadingState } from "./states" +import { + Table, + TableBody, + TableCell, + TableEmptyRow, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "./table" +import { Heading, Text } from "./text" + +describe("Text", () => { + it("renders a paragraph by default", () => { + render(Body copy) + expect(screen.getByText("Body copy").tagName).toBe("P") + }) + + it("applies the tone class", () => { + render(Muted) + expect(screen.getByText("Muted")).toHaveClass("text-muted-foreground") + }) + + it("renders as another element via the render prop", () => { + render(}>Inline) + expect(screen.getByText("Inline").tagName).toBe("SPAN") + }) + + it("has no accessibility violations", async () => { + const { container } = render(Accessible copy) + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe("Heading", () => { + it("maps level to the matching heading tag", () => { + render(Page title) + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Page title") + }) + + it("defaults to level 2", () => { + render(Section) + expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument() + }) + + it("has no accessibility violations", async () => { + const { container } = render(Section) + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe("NumericText", () => { + it("always uses tabular figures so columns align", () => { + render(1,234.00) + expect(screen.getByText("1,234.00")).toHaveClass("tabular-nums") + }) + + it.each([ + ["positive", "text-success"], + ["negative", "text-destructive"], + ["warning", "text-warning"], + ["accent", "text-primary"], + ["muted", "text-muted-foreground"], + ] as const)("maps the %s role onto a semantic token", (role, expected) => { + render(42) + expect(screen.getByText("42")).toHaveClass(expected) + }) + + it("derives a role from a signed value", () => { + expect(numericRoleForValue(1)).toBe("positive") + expect(numericRoleForValue(-1)).toBe("negative") + expect(numericRoleForValue(0)).toBe("neutral") + }) +}) + +describe("Stat", () => { + it("renders label and value", () => { + render() + expect(screen.getByText("Total earned")).toBeInTheDocument() + expect(screen.getByText("$12.00")).toBeInTheDocument() + }) + + it("swaps the value for a skeleton while loading", () => { + const { container } = render() + expect(screen.queryByText("$12.00")).not.toBeInTheDocument() + expect(container.querySelector(".animate-pulse")).toBeInTheDocument() + }) + + it("has no accessibility violations", async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe("LoadingButton", () => { + it("keeps its accessible name while idle", () => { + render(Claim) + expect(screen.getByRole("button", { name: "Claim" })).toBeEnabled() + }) + + it("disables itself and swaps in the loading label", () => { + render( + + Claim + + ) + const button = screen.getByRole("button", { name: "Claiming" }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute("aria-busy", "true") + }) + + it("falls back to the children when no loadingText is given", () => { + render(Claim) + expect(screen.getByRole("button", { name: "Claim" })).toBeDisabled() + }) + + it("does not fire onClick while loading", async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render( + + Claim + + ) + await user.click(screen.getByRole("button", { name: "Claiming" })) + expect(onClick).not.toHaveBeenCalled() + }) + + it("has no accessibility violations in either state", async () => { + const idle = render(Claim) + expect(await axe(idle.container)).toHaveNoViolations() + + const loading = render( + + Claim + + ) + expect(await axe(loading.container)).toHaveNoViolations() + }) +}) + +describe("Spinner", () => { + it("is hidden from assistive tech by default", () => { + const { container } = render() + expect(container.firstChild).toHaveAttribute("aria-hidden", "true") + }) + + it("announces itself when given a label", () => { + render() + expect(screen.getByRole("status", { name: "Loading prices" })).toBeInTheDocument() + }) +}) + +describe("Alert", () => { + it("uses role=status for non-destructive variants", () => { + render( + + Switch networks + + ) + expect(screen.getByRole("status")).toHaveTextContent("Switch networks") + }) + + it("escalates to role=alert for the danger variant", () => { + render( + + Failed + + ) + expect(screen.getByRole("alert")).toHaveTextContent("Failed") + }) + + it("has no accessibility violations", async () => { + const { container } = render( + + Heads up + Rewards are accruing. + + ) + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe("state primitives", () => { + it("LoadingState announces itself and renders the requested rows", () => { + const { container } = render() + expect(screen.getByRole("status")).toBeInTheDocument() + expect(screen.getByText("Loading assets")).toBeInTheDocument() + expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3) + }) + + it("EmptyState renders title, description and action", () => { + render( + Browse pools} + /> + ) + expect(screen.getByText("No deposits")).toBeInTheDocument() + expect(screen.getByText("Start earning by depositing")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Browse pools" })).toBeInTheDocument() + }) + + it("ErrorState exposes an alert and an optional retry", async () => { + const onRetry = vi.fn() + const user = userEvent.setup() + render() + + expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong") + await user.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledOnce() + }) + + it("has no accessibility violations", async () => { + const empty = render() + expect(await axe(empty.container)).toHaveNoViolations() + + const error = render( {}} />) + expect(await axe(error.container)).toHaveNoViolations() + }) +}) + +describe("Card", () => { + it("renders header and content", () => { + render( + + + My assets + + Body + + ) + expect(screen.getByRole("heading", { name: "My assets" })).toBeInTheDocument() + expect(screen.getByText("Body")).toBeInTheDocument() + }) + + it("has no accessibility violations", async () => { + const { container } = render( + + Card body + + ) + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe("Table", () => { + function ExampleTable({ empty = false }: { empty?: boolean }) { + return ( + + + + Epoch + Amount + + + + {empty ? ( + + + + ) : ( + + W-12 + + $125.50 + + + )} + +
+ ) + } + + it("renders column headers and cells", () => { + render() + expect(screen.getByRole("columnheader", { name: "Epoch" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "W-12" })).toBeInTheDocument() + }) + + it("hosts an empty state spanning every column", () => { + render() + const cell = screen.getByRole("cell") + expect(cell).toHaveAttribute("colspan", "2") + expect(screen.getByText("No distributions yet")).toBeInTheDocument() + }) + + it("scrolls horizontally instead of widening the page", () => { + const { container } = render() + expect(container.querySelector('[data-slot="table-container"]')).toHaveClass( + "overflow-x-auto" + ) + }) + + it("has no accessibility violations", async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/slider.test.tsx b/packages/ui/src/components/slider.test.tsx index 71d43a1..95893f8 100644 --- a/packages/ui/src/components/slider.test.tsx +++ b/packages/ui/src/components/slider.test.tsx @@ -15,9 +15,10 @@ describe("Slider accessibility", () => { const user = userEvent.setup() const { container } = render() - const slider = container.querySelector("[role='slider']") - expect(slider).toHaveAttribute("aria-valuemin", "0") - expect(slider).toHaveAttribute("aria-valuemax", "100") + // Base UI renders the accessible control as a visually hidden range input + const slider = container.querySelector("input[type='range']") + expect(slider).toHaveAttribute("min", "0") + expect(slider).toHaveAttribute("max", "100") expect(slider).toHaveAttribute("aria-valuenow") }) }) diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx new file mode 100644 index 0000000..9c88ce3 --- /dev/null +++ b/packages/ui/src/components/spinner.tsx @@ -0,0 +1,28 @@ +import { cn } from "@workspace/ui/lib/utils" + +type SpinnerProps = React.ComponentProps<"span"> & { + /** + * Accessible label. Omit when the spinner sits inside a control that already + * announces its busy state (e.g. `LoadingButton`) so the button keeps its + * own accessible name. + */ + label?: string +} + +function Spinner({ className, label, ...props }: SpinnerProps) { + return ( + + ) +} + +export { Spinner } diff --git a/packages/ui/src/components/stat.tsx b/packages/ui/src/components/stat.tsx new file mode 100644 index 0000000..0464eb3 --- /dev/null +++ b/packages/ui/src/components/stat.tsx @@ -0,0 +1,69 @@ +import { NumericText } from "@workspace/ui/components/numeric" +import { Skeleton } from "@workspace/ui/components/skeleton" +import { Text } from "@workspace/ui/components/text" +import { cn } from "@workspace/ui/lib/utils" +import type { NumericRole } from "@workspace/ui/components/numeric" +import type { ComponentProps } from "react" + +type StatProps = Omit, "role"> & { + label: string + value: React.ReactNode + /** Semantic role for the value — drives its colour. */ + role?: NumericRole + size?: ComponentProps["size"] + weight?: ComponentProps["weight"] + isLoading?: boolean + /** Rendered under the value (e.g. "Performance APY"). */ + hint?: string + /** Renders the label as a small uppercase caption. */ + uppercase?: boolean +} + +/** + * Label + numeric value pair. The single place financial figures pick up + * tabular numerals, so columns of amounts always align. + */ +function Stat({ + label, + value, + role = "neutral", + size = "base", + weight = "medium", + isLoading = false, + hint, + uppercase = false, + className, + ...props +}: StatProps) { + return ( +
+ } + > + {label} + + {isLoading ? ( + + ) : ( + + {value} + + )} + {hint && ( + }> + {hint} + + )} +
+ ) +} + +export { Stat } diff --git a/packages/ui/src/components/states.tsx b/packages/ui/src/components/states.tsx new file mode 100644 index 0000000..a5ec44b --- /dev/null +++ b/packages/ui/src/components/states.tsx @@ -0,0 +1,127 @@ +import { Button } from "@workspace/ui/components/button" +import { Skeleton } from "@workspace/ui/components/skeleton" +import { Text } from "@workspace/ui/components/text" +import { cn } from "@workspace/ui/lib/utils" + +/** + * Skeleton placeholder for a list or table body. + * + * Announces itself through an `sr-only` status message so screen-reader users + * are told content is loading instead of hearing nothing. + */ +function LoadingState({ + rows = 3, + label = "Loading…", + rowClassName, + className, + ...props +}: React.ComponentProps<"div"> & { + rows?: number + label?: string + rowClassName?: string +}) { + return ( +
+ {label} + {Array.from({ length: rows }, (_, i) => ( + + ))} +
+ ) +} + +type EmptyStateProps = React.ComponentProps<"div"> & { + icon?: React.ReactNode + title: string + description?: string + action?: React.ReactNode +} + +function EmptyState({ + icon, + title, + description, + action, + className, + ...props +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+ + {title} + + {description && ( + + {description} + + )} +
+ {action} +
+ ) +} + +type ErrorStateProps = React.ComponentProps<"div"> & { + title?: string + description?: string + onRetry?: () => void + retryLabel?: string +} + +function ErrorState({ + title = "Something went wrong", + description, + onRetry, + retryLabel = "Try again", + className, + ...props +}: ErrorStateProps) { + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ {onRetry && ( + + )} +
+ ) +} + +export { LoadingState, EmptyState, ErrorState } diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx new file mode 100644 index 0000000..6597393 --- /dev/null +++ b/packages/ui/src/components/table.tsx @@ -0,0 +1,126 @@ +import { cn } from "@workspace/ui/lib/utils" + +/** + * Shared data-table primitives. + * + * `Table` wraps the `` in its own horizontally scrollable container so + * wide financial tables never push the page body sideways on mobile. + */ +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<"table"> & { containerClassName?: string }) { + return ( +
+
+ + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return +} + +/** Header row. Carries the muted fill + bottom rule. */ +function TableHeadRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ + className, + align = "left", + ...props +}: Omit, "align"> & { + align?: "left" | "right" +}) { + return ( + + ) +} + +function TableCell({ + className, + align = "left", + ...props +}: Omit, "align"> & { + align?: "left" | "right" +}) { + return ( + + + + ) +} + +export { + Table, + TableHeader, + TableBody, + TableHeadRow, + TableHead, + TableRow, + TableCell, + TableEmptyRow, +} diff --git a/packages/ui/src/components/tabs.test.tsx b/packages/ui/src/components/tabs.test.tsx index b549aa2..d6c288a 100644 --- a/packages/ui/src/components/tabs.test.tsx +++ b/packages/ui/src/components/tabs.test.tsx @@ -38,11 +38,11 @@ describe("Tabs accessibility", () => { const tab1 = screen.getByRole("tab", { name: "Tab 1" }) const tab2 = screen.getByRole("tab", { name: "Tab 2" }) - expect(tab1).toHaveAttribute("data-state", "active") + expect(tab1).toHaveAttribute("aria-selected", "true") await user.click(tab2) - expect(tab2).toHaveAttribute("data-state", "active") - expect(tab1).toHaveAttribute("data-state", "inactive") + expect(tab2).toHaveAttribute("aria-selected", "true") + expect(tab1).toHaveAttribute("aria-selected", "false") expect(screen.getByText("Content 2")).toBeInTheDocument() }) @@ -60,6 +60,6 @@ describe("Tabs accessibility", () => { ) const tab1 = screen.getByRole("tab", { name: "Tab 1" }) - expect(tab1).toHaveAttribute("data-state") + expect(tab1).toHaveAttribute("aria-selected") }) }) diff --git a/packages/ui/src/components/text.tsx b/packages/ui/src/components/text.tsx new file mode 100644 index 0000000..3f02324 --- /dev/null +++ b/packages/ui/src/components/text.tsx @@ -0,0 +1,117 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +/** + * Typography scale for body copy. + * + * Feature code should pick a `size`/`tone` pair instead of reaching for + * arbitrary values like `text-[11px] text-muted-foreground/60`. + */ +const textVariants = cva("", { + variants: { + size: { + "2xs": "text-[0.625rem] leading-4", + xs: "text-[0.6875rem] leading-4", + sm: "text-xs leading-5", + md: "text-[0.8125rem] leading-5", + base: "text-sm leading-5", + lg: "text-base leading-6", + }, + tone: { + default: "text-foreground", + muted: "text-muted-foreground", + subtle: "text-muted-foreground/60", + primary: "text-primary", + success: "text-success", + warning: "text-warning", + info: "text-info", + danger: "text-destructive", + }, + weight: { + normal: "font-normal", + medium: "font-medium", + semibold: "font-semibold", + bold: "font-bold", + }, + /** `label` renders the small uppercase caption used above stat values. */ + variant: { + body: "", + label: "uppercase tracking-wider", + leading: "leading-relaxed", + }, + truncate: { + true: "min-w-0 truncate", + false: "", + }, + }, + defaultVariants: { + size: "sm", + tone: "default", + weight: "normal", + variant: "body", + truncate: false, + }, +}) + +function Text({ + className, + size, + tone, + weight, + variant, + truncate, + render, + ...props +}: useRender.ComponentProps<"p"> & VariantProps) { + return useRender({ + defaultTagName: "p", + props: mergeProps<"p">( + { + className: cn( + textVariants({ size, tone, weight, variant, truncate }), + className + ), + }, + props + ), + render, + state: { slot: "text", tone }, + }) +} + +const headingVariants = cva("tracking-tight text-foreground", { + variants: { + level: { + 1: "text-[1.375rem] font-semibold", + 2: "text-[0.9375rem] font-semibold", + 3: "text-[0.8125rem] font-semibold", + 4: "text-xs font-semibold", + }, + }, + defaultVariants: { level: 2 }, +}) + +type HeadingProps = useRender.ComponentProps<"h2"> & + VariantProps + +/** + * Section heading. `level` drives both the rendered tag and the type scale, so + * a page title is always `level={1}` and a card title `level={3}`. + */ +function Heading({ className, level = 2, render, ...props }: HeadingProps) { + return useRender({ + defaultTagName: `h${level ?? 2}`, + props: mergeProps<"h2">( + { className: cn(headingVariants({ level }), className) }, + props + ), + render, + state: { slot: "heading", level }, + }) +} + +export { Text, Heading, textVariants, headingVariants } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index eae316c..343de09 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -27,6 +27,15 @@ --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-long: var(--long); + --color-short: var(--short); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -67,6 +76,7 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -75,6 +85,30 @@ --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); + + /* Semantic status roles — shared by badges, alerts, numeric text and the + trading chart so a "long" candle is the same green as a positive PnL. */ + --success: oklch(0.596 0.145 163.225); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.681 0.162 75.834); + --warning-foreground: oklch(0.21 0 0); + --info: oklch(0.546 0.245 262.881); + --info-foreground: oklch(0.985 0 0); + --long: var(--success); + --short: var(--destructive); + + /* Trading chart surface roles. The chart renders to canvas and cannot use + Tailwind classes, so it reads these through the typed chart adapter. */ + --chart-surface: var(--background); + --chart-text: var(--muted-foreground); + --chart-grid: var(--border); + --chart-crosshair: oklch(0.708 0 0); + --chart-crosshair-label: oklch(0.955 0 0); + --chart-border: var(--border); + --chart-up: var(--long); + --chart-down: var(--short); + --chart-liquidation: var(--warning); + --radius: 0; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -102,6 +136,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.145 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -110,6 +145,26 @@ --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); + + --success: oklch(0.792 0.184 158.6); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.828 0.159 82.5); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.707 0.165 254.624); + --info-foreground: oklch(0.145 0 0); + --long: var(--success); + --short: var(--destructive); + + --chart-surface: oklch(0.145 0 0); + --chart-text: var(--muted-foreground); + --chart-grid: oklch(0.269 0 0); + --chart-crosshair: oklch(0.44 0 0); + --chart-crosshair-label: oklch(0.269 0 0); + --chart-border: oklch(0.269 0 0); + --chart-up: var(--long); + --chart-down: var(--short); + --chart-liquidation: var(--warning); + --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.685 0.169 237.323); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 3c8bbee..4f9a96c 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -5,8 +5,9 @@ export default mergeConfig( reactConfig, defineConfig({ test: { + // setupFiles comes from reactConfig (./vitest.setup.ts) — mergeConfig + // concatenates arrays, so overriding it here would add a second entry. include: ["src/**/*.{test,spec}.{ts,tsx}"], - setupFiles: ["./setup-tests.ts"], deps: { inline: [ "react", diff --git a/packages/ui/vitest.setup.ts b/packages/ui/vitest.setup.ts new file mode 100644 index 0000000..f08e265 --- /dev/null +++ b/packages/ui/vitest.setup.ts @@ -0,0 +1,12 @@ +import "@testing-library/jest-dom/vitest" +// vitest-axe@0.1.0 ships an empty `extend-expect` entry, so the matcher has to +// be registered by hand (its typings live in ./axe-matchers.d.ts). +import * as axeMatchers from "vitest-axe/matchers" +import { afterEach, expect } from "vitest" +import { cleanup } from "@testing-library/react" + +expect.extend(axeMatchers) + +afterEach(() => { + cleanup() +})
+ ) +} + +function TableRow({ + className, + interactive = true, + ...props +}: React.ComponentProps<"tr"> & { interactive?: boolean }) { + return ( +
+ ) +} + +/** Full-width row used to host an `EmptyState` inside a table body. */ +function TableEmptyRow({ + colSpan, + className, + children, + ...props +}: React.ComponentProps<"td"> & { colSpan: number }) { + return ( +
+ {children} +