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%
+
+
+
+
+
+ )
+}
+
+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 e38be37..669374c 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,
@@ -94,25 +95,26 @@ 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={
- void handleVest()}
>
- {vestPending ? "Starting…" : "Vest now"}
-
+ Vest now
+
}
>
-
-
-
+
+
@@ -127,18 +129,20 @@ export function AdditionalOpportunitiesTab() {
disabled={compoundPending || !hasMultiplierPoints}
onClick={() => void handleCompound()}
>
- {compoundPending ? "Compounding…" : "Compound"}
-
+ Compound
+
}
>
-
-
-
+
+
@@ -155,9 +159,9 @@ export function AdditionalOpportunitiesTab() {
}
>
-
-
-
+
+
+
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 6738e9b..f194f8b 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,25 +41,27 @@ 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 (
-
{children}
-
+
)
}
-type SortButtonProps = {
- active: boolean
- onClick: () => void
- children: React.ReactNode
-}
-
-function SortButton({ active, onClick, children }: SortButtonProps) {
+function SortButton({ active, onClick, children }: ToggleButtonProps) {
return (
-
{children}
-
+
)
}
@@ -162,7 +176,9 @@ export function DiscoverTab() {
setSort("apy")}>
APY {sort === "apy" && "↓"}
- ·
+ }>
+ ·
+
setSort("tvl")}>
TVL {sort === "tvl" && "↓"}
@@ -190,39 +206,36 @@ export function DiscoverTab() {
Pending Rewards
{formatToken(fromSorobanAmount(stakingInfo.pendingEsSO4Rewards, 7), "esSO4")}
-
-
+
+
)}
{/* Pool table */}
-
-
-
-
-
- Pool
- Type
- APY
- TVL
- Position
- Composition
- Action
-
-
-
- {rows.map((row, i) => (
-
- ))}
-
-
-
-
+
+
+
+
+ Pool
+ Type
+ APY
+ TVL
+ Position
+ Composition
+ Action
+
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+
{/* Legend */}
@@ -236,7 +249,7 @@ export function DiscoverTab() {
APY based on trailing 30-day performance
-
+
{/* Deposit modal */}
@@ -252,15 +265,16 @@ export function DiscoverTab() {
{!account ? (
-
+
Connect your wallet to deposit.
-
+
) : (
-
+ }>
Amount (USD)
-
+
Promise
+ onEarn: (id: string, kind: "gm" | "glv", name: string) => void
}) {
const { data: poolAmounts } = useMarketPoolAmounts(row.marketAddress)
const { data: gmPoolData } = useGMPoolData(row.marketAddress)
@@ -332,8 +344,8 @@ function DiscoverRow({
: fromSorobanAmount(glvVaultData?.userGlvBalance ?? 0n, 7)
return (
-
-
+
+
{row.name}
@@ -366,12 +378,17 @@ function DiscoverRow({
) : (
Diversified
)}
-
-
- void onEarn(row.id, row.kind, row.name)}>
- {pending === row.id ? "…" : "Earn"}
-
-
-
+
+
+ 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 c35e2be..16186fe 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,27 +26,26 @@ export function OpportunityCard({
actionLabel = "Earn",
}: OpportunityCardProps) {
return (
-
-
-
-
- {tokens.map((symbol, i) => (
-
- ))}
+
+
+
+
+
+ {tokens.map((symbol, i) => (
+
+ ))}
+
+
}>
+ {name}
+
{name}
-
@@ -58,7 +60,6 @@ export function OpportunityCard({
{formatUsd(tvlUsd, { compact: true })}
-
@@ -43,7 +53,7 @@ export function DistributionsTab() {
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 17d4c4e..29869c0 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",
@@ -56,7 +69,7 @@ function StatusBadge({ status }: { status: DistributionStatus }) {
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
}
diff --git a/apps/web/src/features/earn/components/earn-page.tsx b/apps/web/src/features/earn/components/earn-page.tsx
index c380820..b4e5d08 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"
@@ -14,7 +15,7 @@ export function EarnPage() {
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 a4cbce7..2ac0d66 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 (
@@ -110,119 +120,141 @@ export function AssetsList() {
{isLoading ? (
-
+
) : !hasAny ? (
-
+ }
+ title="You have no deposits"
+ description="Start earning by depositing into a pool"
+ action={
+ }
+ className="hover:text-primary/80"
+ >
+ Browse pools →
+
+ }
+ />
) : (
-
-
-
-
- Asset
- Type
- APY
- Value
-
-
-
-
- {hasSO4 && (
-
- SO4
-
-
-
- —
-
- {formatUsd(so4Stats.stakedValueUsd)}
-
-
-
- void runAction("so4", () =>
- unstakeSO4("DUMMY_ACCOUNT", so4Stats.stakedAmount),
- )
- }
- >
- {pending === "so4" ? "…" : "Unstake"}
-
-
-
- )}
+
+
+
+ Asset
+ Type
+ APY
+ Value
+
+
+
+
+ {hasSO4 && (
+
+ SO4
+
+
+
+
+ —
+
+
+ {formatUsd(so4Stats.stakedValueUsd)}
+
+
+
+ void runAction("so4", () =>
+ unstakeSO4("DUMMY_ACCOUNT", so4Stats.stakedAmount),
+ )
+ }
+ >
+ Unstake
+
+
+
+ )}
- {gmPositions.map((pos) => (
-
- {pos.poolName}
-
-
-
-
+ {gmPositions.map((pos) => (
+
+ {pos.poolName}
+
+
+
+
+
{formatPct(pos.apy, { sign: false })}
-
- {formatUsd(pos.balanceUsd)}
-
-
- void runAction(pos.poolId, () =>
- withdrawGM("DUMMY_ACCOUNT", pos.poolName, pos.balanceTokens),
- )
- }
- >
- {pending === pos.poolId ? "…" : "Sell"}
-
-
-
- ))}
+
+
+
+ {formatUsd(pos.balanceUsd)}
+
+
+
+ void runAction(pos.poolId, () =>
+ withdrawGM("DUMMY_ACCOUNT", pos.poolName, pos.balanceTokens),
+ )
+ }
+ >
+ Sell
+
+
+
+ ))}
- {glvPositions.map((pos) => (
-
-
- {pos.vaultName}{" "}
- [{pos.displayPair}]
-
-
-
-
-
+ {glvPositions.map((pos) => (
+
+
+ {pos.vaultName}{" "}
+ }>
+ [{pos.displayPair}]
+
+
+
+
+
+
+
{formatPct(pos.apy, { sign: false })}
-
- {formatUsd(pos.balanceUsd)}
-
-
- void runAction(pos.vaultId, () =>
- withdrawGLV(
- "DUMMY_ACCOUNT",
- `${pos.vaultName} [${pos.displayPair}]`,
- pos.balanceTokens,
- ),
- )
- }
- >
- {pending === pos.vaultId ? "…" : "Sell"}
-
-
-
- ))}
-
-
-
+
+
+
+ {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 35c42de..92cc7c6 100644
--- a/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx
+++ b/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx
@@ -1,6 +1,10 @@
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"
@@ -80,6 +84,14 @@ function SO4LogoIcon() {
)
}
+function SectionLabel({ children }: { children: React.ReactNode }) {
+ return (
+ }>
+ {children}
+
+ )
+}
+
function SO4Card() {
const [pending, setPending] = useState(false)
@@ -95,7 +107,7 @@ function SO4Card() {
SO4
-
+
@@ -110,8 +122,8 @@ function SO4Card() {
onClick={handleBuy}
>
Buy SO4
-
-
+
+
)
}
@@ -135,12 +147,14 @@ function GlvCard() {
GLV vaults
-
+
{vault.name}{" "}
- [{vault.displayPair}]
-
+
}>
+ [{vault.displayPair}]
+
+
{formatPct(vault.apy, { sign: false })}
Performance APY
@@ -152,10 +166,10 @@ function GlvCard() {
disabled={pending}
onClick={() => void handleEarn()}
>
- {pending ? "…" : "Earn"}
-
+ Earn
+
-
+
)
}
@@ -174,7 +188,7 @@ function GmCard() {
}
return (
-
+
GM pools
@@ -182,7 +196,7 @@ function GmCard() {
Explore more
-
+
@@ -193,24 +207,24 @@ function GmCard() {
{pool.name}
[{pool.longToken}-{pool.shortToken}]
-
+
{formatPct(pool.apy, { sign: false })}
Performance APY
- void handleEarn(pool.id, pool.name)}
>
- {pending === pool.id ? "…" : "Earn"}
-
+ Earn
+
))}
-
+
)
}
diff --git a/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx b/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
index 495f23b..907932a 100644
--- a/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
+++ b/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
@@ -72,7 +72,7 @@ describe("RewardsBar — portfolio rewards (#235)", () => {
const { RewardsBar } = await import("./rewards-bar")
const { container } = render(
)
- const skeletons = container.querySelectorAll(".animate-pulse")
+ const skeletons = container.querySelectorAll("[data-slot='skeleton']")
expect(skeletons.length).toBeGreaterThan(0)
})
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 843aeab..340ceda 100644
--- a/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx
+++ b/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx
@@ -1,6 +1,9 @@
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"
@@ -85,18 +88,18 @@ export function RewardsBar() {
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.
-
+
setBannerOpen(false)}
- className="mt-0.5 shrink-0 text-blue-400/50 transition-colors hover:text-blue-400"
+ className="mt-0.5 shrink-0 opacity-50 transition-opacity hover:opacity-100"
>
-
+
)}
-
-
+
-
+
- 0 ? "positive" : "neutral"}
isLoading={isLoading}
/>
-
+
-
-
+
-
- void handleClaim()}
className="h-9 gap-2 text-xs"
>
- {claiming ? "Claiming…" : "Claim rewards"}
-
+ 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({
)}
- void handleSubmit()}
>
- {mutation.isPending
- ? "Confirming..."
- : action === "stake"
- ? "Stake SO4"
- : "Unstake SO4"}
-
+ {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 c0eda2f..1acb488 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,12 +49,16 @@ function TokenCard({
: "No claim recorded"
return (
-
+
-
-
{token.symbol}
-
{token.name}
+
+
+ {token.symbol}
+
+
+ {token.name}
+
@@ -85,22 +94,17 @@ function TokenCard({
{cooldownText}
onClaim(token)}
>
- {isPending ? (
-
-
- Claiming
-
- ) : (
- "Claim"
- )}
-
+ Claim
+
-
+
)
}
@@ -129,19 +133,21 @@ export function FaucetPage() {
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,20 +169,22 @@ export function FaucetPage() {
{/* Claim panel */}
-
-
+
+
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 ? (
@@ -185,30 +193,26 @@ export function FaucetPage() {
) : (
-
claimAll()}
>
- {isBulkPending ? (
-
-
- Claiming…
-
- ) : (
- "Claim Test Tokens"
- )}
-
+ Claim Test Tokens
+
)}
{data?.cooldownLedgers != null && data.cooldownLedgers > 0 && (
Cooldown: {data.cooldownLedgers.toLocaleString()} ledgers between claims
-
+
)}
-
-
+
+
{/* Info panel */}
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({
-
Referral Code
+
}
+ 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."}
+
-
void handleSubmit()}
>
- {mutation.isPending ? "Creating..." : "Create Code"}
-
+ 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 a2aced8..98388dd 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 }) {
@@ -89,8 +104,9 @@ function CreateCodeForm({ onSuccess }: { onSuccess: () => void }) {
}
>
{pending ? "Creating…" : "Create"}
@@ -102,8 +118,7 @@ function CreateCodeForm({ onSuccess }: { onSuccess: () => void }) {
}
{code.length}/16
-
-
+
{/* Tier table */}
@@ -142,8 +157,8 @@ function CreateCodeForm({ onSuccess }: { onSuccess: () => void }) {
-
-
+
+
)
}
@@ -202,42 +217,43 @@ function ReferralsTable() {
Referrals
{isLoading ? (
-
-
-
-
+
) : referrals.length === 0 ? (
-
-
No referrals yet
-
- Share your code to start earning commissions
-
-
+
) : (
-
-
-
-
- Account
- Volume
- Commission
- Since
-
-
-
- {referrals.map((r) => (
-
- {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}
+
+ ))}
+
+
)}
-
+
)
}
@@ -272,7 +288,7 @@ export function AffiliatesTab() {
{isLoading ? (
-
+
diff --git a/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx b/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx
index 0fec674..692e6a7 100644
--- a/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx
+++ b/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx
@@ -1,12 +1,39 @@
import { useState } from "react"
-import { Button } from "@workspace/ui/components/button"
-
+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 } from "@workspace/ui/components/states"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableEmptyRow,
+ TableHead,
+ TableHeadRow,
+ TableHeader,
+ TableRow,
+} from "@workspace/ui/components/table"
+import { Heading, Text } from "@workspace/ui/components/text"
import { useAffiliateStats, useDistributions } from "../../hooks/use-referrals-data"
import { claimDistribution } from "../../lib/referrals"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
import { formatToken, formatUsd } from "@/shared/lib/format"
+const SCHEDULE_FACTS = [
+ { label: "Distribution cycle", value: "Weekly (Thu)" },
+ { label: "Payment token", value: "USDC" },
+ { label: "Claim window", value: "No expiry" },
+]
+function LockIcon() {
+ return (
+
+
+
+
+ )
+}
export function DistributionsTab() {
const account = useWalletStore((state) => state.address)
@@ -28,20 +55,13 @@ 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."
+ />
+
)
}
diff --git a/apps/web/src/features/referrals/components/referrals-page.tsx b/apps/web/src/features/referrals/components/referrals-page.tsx
index 272d1e4..3b5db4f 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"
@@ -52,7 +53,7 @@ export function ReferralsPage() {
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 */}
+
= [
@@ -67,7 +69,7 @@ function ExternalLink({ href, children }: ExternalLinkProps) {
-
+
)
}
@@ -92,7 +94,7 @@ export function ReferralsSidebar({
const faqs = isAffiliate ? AFFILIATE_FAQS : TRADER_FAQS
return (
-
-
+
+
+
+
+ {onEdit && (
+
+
+ Edit
+
+ )}
+
+
+
)
}
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 04cae21..60bde85 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,11 +27,15 @@ function ChevronIcon({ open }: { open: boolean }) {
function AccordionItem({ item }: { item: FaqItem }) {
const [open, setOpen] = useState(false)
+ const panelId = useId()
return (
setOpen((v) => !v)}
+ aria-expanded={open}
+ aria-controls={panelId}
className="flex w-full items-center justify-between gap-3 py-3 text-left"
>
{item.q}
@@ -37,6 +43,7 @@ function AccordionItem({ item }: { item: FaqItem }) {
{
const now = new Date()
const fmt = (d: Date) =>
@@ -39,12 +37,20 @@ 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) {
@@ -52,7 +58,7 @@ export function StatChartCard({ title, tooltip, value, period, accent = "blue" }
const accentStrokeClass = accent === "green" ? "stroke-green-400" : "stroke-blue-400"
return (
-
+
{title}
@@ -62,7 +68,7 @@ export function StatChartCard({ title, tooltip, value, period, accent = "blue" }
{formatUsd(value)}
-
+
{/* Chart area */}
@@ -131,6 +137,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 8251c5c..2584d15 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 = {
@@ -36,12 +40,12 @@ export function TierProgress({ tier, volumeUsd }: Props) {
const remaining = Math.max(next.minVolumeUsd - volumeUsd, 0)
return (
-
-
+
{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 b6d93cb..7df0121 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,27 @@ type Props = {
export function TimePeriodFilter({ value, onChange }: Props) {
return (
-
+
{PERIODS.map((p) => (
- onChange(p.value)}
className={cn(
"rounded-md px-2.5 py-1 text-11 font-medium transition-colors",
value === p.value
? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground",
+ : "text-muted-foreground",
)}
>
{p.label}
-
+
))}
)
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 a0b09d9..e7ece7a 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
}
@@ -99,11 +121,42 @@ function JoinCodeForm({ onSuccess }: JoinCodeFormProps) {
}
>
- {pending ? "Applying…" : "Apply"}
-
+ 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}
}
@@ -128,8 +181,8 @@ function JoinCodeForm({ onSuccess }: JoinCodeFormProps) {
))}
-
-
+
+
)
}
@@ -162,7 +215,7 @@ function Overview({
{isLoading ? (
-
+
@@ -200,8 +253,10 @@ function Overview({
{stats?.lastUpdated && (
Last updated:{" "}
- {fmtDate(stats.lastUpdated)}
-
+
+ {fmtDate(stats.lastUpdated)}
+
+
)}
)
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/components/positions/OrdersList.test.tsx b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx
index 096c51e..4eadb00 100644
--- a/apps/web/src/features/trade/components/positions/OrdersList.test.tsx
+++ b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx
@@ -76,7 +76,7 @@ describe("OrdersList", () => {
it("renders loading skeleton when loading", () => {
mockIsLoading = true
const { container } = render(
, { wrapper: createWrapper() })
- expect(container.querySelector(".animate-pulse")).toBeInTheDocument()
+ expect(container.querySelector("[data-slot='skeleton']")).toBeInTheDocument()
})
it("renders empty state when no orders", () => {
diff --git a/apps/web/src/features/trade/components/positions/PositionsList.test.tsx b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx
index 3bc0c4b..039c433 100644
--- a/apps/web/src/features/trade/components/positions/PositionsList.test.tsx
+++ b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx
@@ -131,7 +131,7 @@ describe("PositionsList", () => {
it("renders loading skeleton when loading", () => {
mockIsLoading = true
const { container } = render(
, { wrapper: createWrapper() })
- expect(container.querySelector(".animate-pulse")).toBeInTheDocument()
+ expect(container.querySelector("[data-slot='skeleton']")).toBeInTheDocument()
})
it("renders empty state when no positions", () => {
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 2ce81ff..0000000
--- a/packages/ui/setup-tests.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import "@testing-library/jest-dom/vitest"
-import * as matchers from "vitest-axe/matchers"
-import { afterEach, expect } from "vitest"
-import { cleanup } from "@testing-library/react"
-
-expect.extend(matchers)
-
-afterEach(() => {
- cleanup()
-})
diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx
index f96d638..f7205db 100644
--- a/packages/ui/src/components/alert.tsx
+++ b/packages/ui/src/components/alert.tsx
@@ -5,7 +5,37 @@ import { cn } from "@workspace/ui/lib/utils"
import type { VariantProps } from "class-variance-authority"
// ---------------------------------------------------------------------------
-// ARIA live-region semantics
+// Two call styles, one component
+//
+// 1. Composition (legacy, `variant`):
+// …
+// Children are laid out as direct flex children so a caller-supplied icon
+// sits beside the copy. No icon is injected.
+//
+// 2. Props (`severity` + title/description/icon/action/onDismiss):
+//
+// Content is wrapped in a body slot and a severity icon is supplied by
+// default.
+//
+// `variant` maps onto `severity` for colour ("danger" → "error"), so both
+// styles resolve to the same token-backed surface.
+// ---------------------------------------------------------------------------
+
+type Severity = "info" | "success" | "warning" | "error"
+
+/** Legacy colour names kept for existing `variant` callsites. */
+type AlertVariant = "info" | "success" | "warning" | "danger" | "muted"
+
+const VARIANT_SEVERITY: Record = {
+ info: "info",
+ success: "success",
+ warning: "warning",
+ danger: "error",
+ muted: "muted",
+}
+
+// ---------------------------------------------------------------------------
+// ARIA live-region semantics (severity API)
//
// severity | role | aria-live | aria-atomic
// ----------|---------|------------|------------
@@ -13,10 +43,12 @@ import type { VariantProps } from "class-variance-authority"
// success | status | polite | false
// warning | alert | assertive | true – demands attention
// error | alert | assertive | true – must be announced immediately
+//
+// The `variant` API predates this table and only escalates for "danger", so
+// it keeps its own mapping rather than silently making every existing
+// `variant="warning"` callsite assertive.
// ---------------------------------------------------------------------------
-type Severity = "info" | "success" | "warning" | "error"
-
const SEVERITY_ROLE: Record = {
info: "status",
success: "status",
@@ -24,43 +56,20 @@ const SEVERITY_ROLE: Record = {
error: "alert",
}
-const SEVERITY_LIVE: Record = {
- info: "polite",
- success: "polite",
- warning: "assertive",
- error: "assertive",
-}
-
-// ---------------------------------------------------------------------------
-// Layout variants
-// ---------------------------------------------------------------------------
-
const alertVariants = cva(
- // Base – shared by both layouts
- "relative flex w-full gap-3 border text-sm transition-colors [&_svg]:shrink-0",
+ "relative flex w-full gap-3 border transition-colors [&>svg]:mt-px [&_svg]:shrink-0",
{
variants: {
severity: {
- info: [
- "border-primary/20 bg-primary/5 text-primary",
- "dark:border-primary/30 dark:bg-primary/10",
- ],
- success: [
- "border-emerald-500/20 bg-emerald-500/5 text-emerald-700",
- "dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-400",
- ],
- warning: [
- "border-amber-500/20 bg-amber-500/5 text-amber-700",
- "dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400",
- ],
- error: [
- "border-destructive/20 bg-destructive/5 text-destructive",
- "dark:border-destructive/30 dark:bg-destructive/10",
- ],
+ 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",
+ error: "border-destructive/30 bg-destructive/10 text-destructive",
+ muted: "border-border bg-muted/30 text-muted-foreground",
},
layout: {
/** Sits inline inside a form, card, or section. */
- inline: "items-start rounded-md px-3.5 py-3",
+ inline: "items-start rounded-lg px-4 py-3",
/** Stretches edge-to-edge as an application-level banner. */
banner: "items-center rounded-none border-x-0 px-4 py-3",
},
@@ -72,17 +81,14 @@ const alertVariants = cva(
}
)
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export interface AlertProps
- extends
- Omit, "role" | "title">,
- VariantProps {
+interface AlertProps
+ extends Omit, "title">,
+ Omit, "severity"> {
/** Severity level – controls colour, icon defaults, and ARIA semantics. */
severity?: Severity
- /** Leading icon. Pass any SVG or icon component. */
+ /** Legacy colour alias for `severity` ("danger" → "error"). */
+ variant?: AlertVariant
+ /** Leading icon. Pass any SVG or icon component, or `null` to suppress. */
icon?: React.ReactNode
/** Bold label / headline rendered before the message. */
title?: React.ReactNode
@@ -174,11 +180,12 @@ function ErrorIcon({ className }: { className?: string }) {
)
}
-const DEFAULT_ICONS: Record = {
+const DEFAULT_ICONS: Record = {
info: ,
success: ,
warning: ,
error: ,
+ muted: null,
}
// Small ✕ used for the dismiss button
@@ -195,12 +202,9 @@ function CloseIcon() {
)
}
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
function Alert({
- severity = "info",
+ severity,
+ variant,
layout = "inline",
icon,
title,
@@ -210,70 +214,82 @@ function Alert({
dismissLabel = "Dismiss",
className,
children,
+ role,
...props
}: AlertProps) {
- const role = SEVERITY_ROLE[severity]
- const ariaLive = SEVERITY_LIVE[severity]
- const ariaAtomic = role === "alert"
+ const resolvedSeverity =
+ severity ?? (variant != null ? VARIANT_SEVERITY[variant] : "info")
- // Resolve icon: explicit prop > severity default
- const resolvedIcon = icon !== undefined ? icon : DEFAULT_ICONS[severity]
+ // `severity` opts into the live-region table above; the legacy `variant`
+ // path only escalates for "danger".
+ const resolvedRole =
+ role ??
+ (severity != null
+ ? SEVERITY_ROLE[severity]
+ : variant === "danger"
+ ? "alert"
+ : "status")
- // Content: prefer explicit title/description props; fall back to children
+ const isAssertive = resolvedRole === "alert"
+
+ // Content: prefer explicit title/description props; fall back to children.
const hasStructured = title != null || description != null
+ // Only the props API injects an icon — legacy children lay themselves out
+ // as direct flex children and supply their own.
+ const resolvedIcon =
+ icon !== undefined
+ ? icon
+ : hasStructured
+ ? DEFAULT_ICONS[resolvedSeverity]
+ : null
+
+ const usesBody = hasStructured || action != null || onDismiss != null
+
+ const body = hasStructured ? (
+ <>
+ {title != null && {title} }
+ {description != null && (
+
+ {description}
+
+ )}
+ >
+ ) : (
+ children
+ )
+
return (
- {/* Leading icon */}
{resolvedIcon != null && (
{resolvedIcon}
)}
- {/* Body */}
-
- {hasStructured ? (
- <>
- {title != null && (
-
- {title}
-
- )}
- {description != null && (
-
- {description}
-
- )}
- >
- ) : (
- children
- )}
+ {usesBody ? (
+
+ {body}
- {/* Inline action */}
- {action != null && (
-
- {action}
-
- )}
-
+ {action != null && (
+
+ {action}
+
+ )}
+
+ ) : (
+ body
+ )}
- {/* Dismiss button */}
{onDismiss != null && (
) {
+ return (
+
+ )
+}
+
+function AlertDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, alertVariants }
+export type { AlertProps, Severity as AlertSeverity, AlertVariant }
diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx
index b5b084c..33cdf89 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 (
+
+ {isLoading ? (
+ <>
+
+ {loadingText ?? children}
+ >
+ ) : (
+ children
+ )}
+
+ )
+}
+
+export { LoadingButton }
diff --git a/packages/ui/src/components/primitives.test.tsx b/packages/ui/src/components/primitives.test.tsx
new file mode 100644
index 0000000..5ce25ce
--- /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("[data-slot='skeleton']")).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("[data-slot='skeleton']")).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 && (
+
+ {retryLabel}
+
+ )}
+
+ )
+}
+
+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 TableRow({
+ className,
+ interactive = true,
+ ...props
+}: React.ComponentProps<"tr"> & { interactive?: boolean }) {
+ return (
+
+ )
+}
+
+function TableCell({
+ className,
+ align = "left",
+ ...props
+}: Omit, "align"> & {
+ align?: "left" | "right"
+}) {
+ 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}
+
+
+ )
+}
+
+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 7d03c0c..475f176 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);
@@ -81,99 +90,128 @@
}
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.5 0.134 242.749);
- --primary-foreground: oklch(0.977 0.013 236.62);
- --secondary: oklch(0.967 0.001 286.375);
- --secondary-foreground: oklch(0.21 0.006 285.885);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --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);
- --radius: 0;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.588 0.158 241.966);
- --sidebar-primary-foreground: oklch(0.977 0.013 236.62);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.5 0.134 242.749);
+ --primary-foreground: oklch(0.977 0.013 236.62);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --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);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --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);
+ --sidebar-primary: oklch(0.588 0.158 241.966);
+ --sidebar-primary-foreground: oklch(0.977 0.013 236.62);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.443 0.11 240.79);
- --primary-foreground: oklch(0.977 0.013 236.62);
- --secondary: oklch(0.274 0.006 286.033);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --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);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.685 0.169 237.323);
- --sidebar-primary-foreground: oklch(0.293 0.066 243.157);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.443 0.11 240.79);
+ --primary-foreground: oklch(0.977 0.013 236.62);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --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);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --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);
+ --sidebar-primary-foreground: oklch(0.293 0.066 243.157);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
- }
+ }
body {
@apply bg-background text-foreground;
- }
- button:not(:disabled),
- [role="button"]:not(:disabled) {
+ }
+ button:not(:disabled), [role="button"]:not(:disabled) {
cursor: pointer;
- }
-}
-
-/* ---------------------------------------------------------------------------
- * Skeleton – reduced-motion safety net
- *
- * `motion-safe:animate-pulse` on the component suppresses the animation at
- * the Tailwind utility level. This @layer base rule is a belt-and-suspenders
- * CSS-level fallback for browsers or render paths where the Tailwind variant
- * is not evaluated (e.g. SSR-only CSS, third-party consumers of the stylesheet).
- * --------------------------------------------------------------------------- */
-@layer base {
- @media (prefers-reduced-motion: reduce) {
- [data-slot="skeleton"] {
- animation: none;
}
- }
-}
+}
\ No newline at end of file
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
index 2d1d1e1..f08e265 100644
--- a/packages/ui/vitest.setup.ts
+++ b/packages/ui/vitest.setup.ts
@@ -1,4 +1,12 @@
-// The shared `@repo/vitest-config/react` config expects `./vitest.setup.ts`.
-// Import the existing setup module so its side-effects (jest-dom matchers,
-// afterEach cleanup) still run when vitest resolves this path.
-import "./setup-tests"
+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()
+})