diff --git a/apps/web/src/features/design-system/design-system-page.tsx b/apps/web/src/features/design-system/design-system-page.tsx index 1657234..ccbc523 100644 --- a/apps/web/src/features/design-system/design-system-page.tsx +++ b/apps/web/src/features/design-system/design-system-page.tsx @@ -2,11 +2,27 @@ import { useState } from "react" import { Button } from "@workspace/ui/components/button" import { Input } from "@workspace/ui/components/input" import { Badge } from "@workspace/ui/components/badge" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@workspace/ui/components/tabs" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@workspace/ui/components/dialog" import { Skeleton } from "@workspace/ui/components/skeleton" -import { useTheme } from "@/ui/theme-provider" +import { Alert, AlertDescription, AlertTitle } from "@workspace/ui/components/alert" +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card" +import { LoadingButton } from "@workspace/ui/components/loading-button" +import { NumericText } from "@workspace/ui/components/numeric" +import { Stat } from "@workspace/ui/components/stat" +import { EmptyState, ErrorState, LoadingState } from "@workspace/ui/components/states" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeadRow, + TableHeader, + TableRow, +} from "@workspace/ui/components/table" +import { Heading, Text } from "@workspace/ui/components/text" import { Separator } from "@workspace/ui/components/separator" +import { useTheme } from "@/ui/theme-provider" function ComponentSection({ title, @@ -195,6 +211,214 @@ function TabsExamples() { ) } +function TypographyExamples() { + return ( +
+
+

Headings

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

Body sizes

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

Tones

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

Semantic roles

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

Stats

+
+ + + +
+
+
+ ) +} + +function SurfaceExamples() { + return ( +
+ + + Card — default surface + + + + + Card — muted surface + + + + + Card — dashed placeholder + + + + + Card — plain frame + + + + Used when the body is a table. + + + +
+ ) +} + +function TableExamples() { + return ( + + + Distribution history + + + + + Epoch + Token + Amount + Status + + + + + + W-12 + + + USDC + + + $125.50 + + + Distributed + + + + + W-13 + + + esSO4 + + + $0.00 + + + Upcoming + + + +
+
+ ) +} + +function AlertExamples() { + return ( +
+ {(["info", "success", "warning", "danger", "muted"] as const).map((variant) => ( + +
+ {variant} + + Shared alert surface — colours come from semantic tokens. + +
+
+ ))} +
+ ) +} + +function StateExamples() { + return ( +
+ + + + + + Browse pools + + } + /> + + + {}} /> + +
+ ) +} + +function LoadingButtonExamples() { + const [loading, setLoading] = useState(false) + + return ( +
+ Claim + + Claim + + { + setLoading(true) + setTimeout(() => setLoading(false), 1500) + }} + > + Try it + +
+ ) +} + export function DesignSystemPage() { const { theme, setTheme } = useTheme() const [viewport, setViewport] = useState<"mobile" | "tablet" | "desktop">("desktop") @@ -251,6 +475,27 @@ export function DesignSystemPage() { Skeletons + + Typography + + + Numeric + + + Surfaces + + + Tables + + + Alerts + + + States + + + Loading buttons + @@ -289,6 +534,60 @@ export function DesignSystemPage() { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) diff --git a/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx b/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx index 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={ - + 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 ( - + ) } -type SortButtonProps = { - active: boolean - onClick: () => void - children: React.ReactNode -} - -function SortButton({ active, onClick, children }: SortButtonProps) { +function SortButton({ active, onClick, children }: ToggleButtonProps) { return ( - + ) } @@ -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 */} -
-
- - - - - - - - - - - - - - {rows.map((row, i) => ( - - ))} - -
PoolTypeAPYTVLPositionCompositionAction
-
-
+ + + + + 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. -

+ ) : (
- + 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 )} - - - - - + + + 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 })}

-
-
+
) } 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 → + + } + /> ) : ( -
- - - - - - - - - - - {hasSO4 && ( - - - - - - - - )} +
AssetTypeAPYValue -
SO4 - - - {formatUsd(so4Stats.stakedValueUsd)} - - -
+ + + Asset + Type + APY + Value + + + + + {hasSO4 && ( + + SO4 + + + + + + + + {formatUsd(so4Stats.stakedValueUsd)} + + + + void runAction("so4", () => + unstakeSO4("DUMMY_ACCOUNT", so4Stats.stakedAmount), + ) + } + > + Unstake + + + + )} - {gmPositions.map((pos) => ( - - - - - - - - ))} + + + + {formatUsd(pos.balanceUsd)} + + + + void runAction(pos.poolId, () => + withdrawGM("DUMMY_ACCOUNT", pos.poolName, pos.balanceTokens), + ) + } + > + Sell + + + + ))} - {glvPositions.map((pos) => ( - - - - - - - - ))} - -
{pos.poolName} - - + {gmPositions.map((pos) => ( + + {pos.poolName} + + + + + {formatPct(pos.apy, { sign: false })} - {formatUsd(pos.balanceUsd)} - -
- {pos.vaultName}{" "} - [{pos.displayPair}] - - - + {glvPositions.map((pos) => ( + + + {pos.vaultName}{" "} + }> + [{pos.displayPair}] + + + + + + + {formatPct(pos.apy, { sign: false })} - {formatUsd(pos.balanceUsd)} - -
-
+ +
+ + {formatUsd(pos.balanceUsd)} + + + + void runAction(pos.vaultId, () => + withdrawGLV( + "DUMMY_ACCOUNT", + `${pos.vaultName} [${pos.displayPair}]`, + pos.balanceTokens, + ), + ) + } + > + Sell + + +
+ ))} + + )} -
+ ) } diff --git a/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx b/apps/web/src/features/earn/components/portfolio/recommended-assets.tsx index 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() { +

@@ -193,24 +207,24 @@ function GmCard() {

{pool.name}

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

+

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

Performance APY

- + 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. -

+ -
+ )} -
- + -
+ - 0 ? "positive" : "neutral"} isLoading={isLoading} /> -
+ - -
+ -
- + Claim rewards +
-
+
) } diff --git a/apps/web/src/features/earn/components/stake/StakeDialog.tsx b/apps/web/src/features/earn/components/stake/StakeDialog.tsx index f356928..3ff626d 100644 --- a/apps/web/src/features/earn/components/stake/StakeDialog.tsx +++ b/apps/web/src/features/earn/components/stake/StakeDialog.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from "@workspace/ui/components/dialog" import { Button } from "@workspace/ui/components/button" +import { LoadingButton } from "@workspace/ui/components/loading-button" import { Input } from "@workspace/ui/components/input" import { useStakeMutation } from "../../hooks/useStakeMutation" @@ -96,17 +97,15 @@ export function StakeDialog({
)} - + {action === "stake" ? "Stake SO4" : "Unstake SO4"} +
diff --git a/apps/web/src/features/earn/lib/badges.ts b/apps/web/src/features/earn/lib/badges.ts new file mode 100644 index 0000000..0417129 --- /dev/null +++ b/apps/web/src/features/earn/lib/badges.ts @@ -0,0 +1,21 @@ +import type { BadgeVariant } from "@workspace/ui/components/badge" + +/** + * Asset-kind → shared Badge variant. + * + * Keeps the three earn products visually distinct without any feature-local + * colour classes: every hue comes from a semantic token. + */ +export const ASSET_KIND_BADGE = { + Staking: "info", + GM: "success", + GLV: "secondary", +} as const satisfies Record + +/** Badge variant for a pool row keyed by its `kind` discriminator. */ +export const POOL_KIND_BADGE = { + gm: ASSET_KIND_BADGE.GM, + glv: ASSET_KIND_BADGE.GLV, +} as const satisfies Record<"gm" | "glv", BadgeVariant> + +export type { BadgeVariant } diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index 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}

+ 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() {
) : ( - + 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({
- + } + className="mb-2 block" + > + Referral Code + - {error &&

{error}

} - {!error && ( -

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

- )} + + {error ?? "Minimum 3 characters. Only letters, numbers, and underscores allowed."} +
- + Create Code +
diff --git a/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx b/apps/web/src/features/referrals/components/affiliates/affiliates-tab.tsx index 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 }) { @@ -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 -

-
+ ) : ( -
- - - - - - - - - - - {referrals.map((r) => ( - - - - - - - ))} - -
AccountVolumeCommissionSince
{formatAddress(r.account)}{formatUsd(r.volumeUsd, { compact: true })}{formatUsd(r.commissionUsd, { compact: true })}{r.registeredAt}
-
+ + + + Account + Volume + Commission + Since + + + + {referrals.map((r) => ( + + + {formatAddress(r.account)} + + + {formatUsd(r.volumeUsd, { compact: true })} + + + + {formatUsd(r.commissionUsd, { compact: true })} + + + {r.registeredAt} + + ))} + +
)} - + ) } @@ -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 && ( + + )} +
+ + ) } 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 (
{ 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) => ( - + ))}
) 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) { + 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 && ( ) diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx new file mode 100644 index 0000000..ae32a7a --- /dev/null +++ b/packages/ui/src/components/card.tsx @@ -0,0 +1,70 @@ +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +const cardVariants = cva("rounded-xl border", { + variants: { + variant: { + /** Default raised surface — cards, panels, sidebars. */ + default: "border-border bg-card", + /** Recessed surface for supporting information. */ + muted: "border-border bg-muted/20", + /** Placeholder surface for "nothing here yet". */ + dashed: "border-dashed border-border bg-muted/10", + /** Frame only — used when the body is a table that paints its own rows. */ + plain: "border-border bg-transparent overflow-hidden", + }, + padding: { + none: "", + sm: "p-4", + md: "p-5", + lg: "p-6", + }, + }, + defaultVariants: { variant: "default", padding: "none" }, +}) + +function Card({ + className, + variant, + padding, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +/** Header strip with a bottom rule — the standard table/card caption row. */ +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardContent, CardFooter, cardVariants } diff --git a/packages/ui/src/components/input.test.tsx b/packages/ui/src/components/input.test.tsx index d553166..0d5996e 100644 --- a/packages/ui/src/components/input.test.tsx +++ b/packages/ui/src/components/input.test.tsx @@ -35,7 +35,7 @@ describe("Input accessibility", () => { }) it("supports invalid state", async () => { - const { container } = render() + const { container } = render() const input = screen.getByRole("textbox") expect(input).toHaveAttribute("aria-invalid", "true") diff --git a/packages/ui/src/components/loading-button.tsx b/packages/ui/src/components/loading-button.tsx new file mode 100644 index 0000000..633e2a7 --- /dev/null +++ b/packages/ui/src/components/loading-button.tsx @@ -0,0 +1,45 @@ +import { Button } from "@workspace/ui/components/button" +import { Spinner } from "@workspace/ui/components/spinner" +import { cn } from "@workspace/ui/lib/utils" +import type { ComponentProps } from "react" + +type LoadingButtonProps = ComponentProps & { + isLoading?: boolean + /** Label shown while `isLoading`. Defaults to the button's children. */ + loadingText?: React.ReactNode +} + +/** + * Button that owns its own pending presentation: disables itself, flags + * `aria-busy` and swaps in a spinner. The spinner is `aria-hidden`, so the + * accessible name is exactly `loadingText` (or the children). + */ +function LoadingButton({ + isLoading = false, + loadingText, + disabled, + className, + children, + ...props +}: LoadingButtonProps) { + return ( + + ) +} + +export { LoadingButton } diff --git a/packages/ui/src/components/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 && ( + + )} +
+ ) +} + +export { LoadingState, EmptyState, ErrorState } diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx new file mode 100644 index 0000000..6597393 --- /dev/null +++ b/packages/ui/src/components/table.tsx @@ -0,0 +1,126 @@ +import { cn } from "@workspace/ui/lib/utils" + +/** + * Shared data-table primitives. + * + * `Table` wraps the `` in its own horizontally scrollable container so + * wide financial tables never push the page body sideways on mobile. + */ +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<"table"> & { containerClassName?: string }) { + return ( +
+
+ + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return +} + +/** Header row. Carries the muted fill + bottom rule. */ +function TableHeadRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ + className, + align = "left", + ...props +}: Omit, "align"> & { + align?: "left" | "right" +}) { + return ( + + ) +} + +function TableCell({ + className, + align = "left", + ...props +}: Omit, "align"> & { + align?: "left" | "right" +}) { + return ( + + + + ) +} + +export { + Table, + TableHeader, + TableBody, + TableHeadRow, + TableHead, + TableRow, + TableCell, + TableEmptyRow, +} diff --git a/packages/ui/src/components/tabs.test.tsx b/packages/ui/src/components/tabs.test.tsx index b549aa2..d6c288a 100644 --- a/packages/ui/src/components/tabs.test.tsx +++ b/packages/ui/src/components/tabs.test.tsx @@ -38,11 +38,11 @@ describe("Tabs accessibility", () => { const tab1 = screen.getByRole("tab", { name: "Tab 1" }) const tab2 = screen.getByRole("tab", { name: "Tab 2" }) - expect(tab1).toHaveAttribute("data-state", "active") + expect(tab1).toHaveAttribute("aria-selected", "true") await user.click(tab2) - expect(tab2).toHaveAttribute("data-state", "active") - expect(tab1).toHaveAttribute("data-state", "inactive") + expect(tab2).toHaveAttribute("aria-selected", "true") + expect(tab1).toHaveAttribute("aria-selected", "false") expect(screen.getByText("Content 2")).toBeInTheDocument() }) @@ -60,6 +60,6 @@ describe("Tabs accessibility", () => { ) const tab1 = screen.getByRole("tab", { name: "Tab 1" }) - expect(tab1).toHaveAttribute("data-state") + expect(tab1).toHaveAttribute("aria-selected") }) }) diff --git a/packages/ui/src/components/text.tsx b/packages/ui/src/components/text.tsx new file mode 100644 index 0000000..3f02324 --- /dev/null +++ b/packages/ui/src/components/text.tsx @@ -0,0 +1,117 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" + +/** + * Typography scale for body copy. + * + * Feature code should pick a `size`/`tone` pair instead of reaching for + * arbitrary values like `text-[11px] text-muted-foreground/60`. + */ +const textVariants = cva("", { + variants: { + size: { + "2xs": "text-[0.625rem] leading-4", + xs: "text-[0.6875rem] leading-4", + sm: "text-xs leading-5", + md: "text-[0.8125rem] leading-5", + base: "text-sm leading-5", + lg: "text-base leading-6", + }, + tone: { + default: "text-foreground", + muted: "text-muted-foreground", + subtle: "text-muted-foreground/60", + primary: "text-primary", + success: "text-success", + warning: "text-warning", + info: "text-info", + danger: "text-destructive", + }, + weight: { + normal: "font-normal", + medium: "font-medium", + semibold: "font-semibold", + bold: "font-bold", + }, + /** `label` renders the small uppercase caption used above stat values. */ + variant: { + body: "", + label: "uppercase tracking-wider", + leading: "leading-relaxed", + }, + truncate: { + true: "min-w-0 truncate", + false: "", + }, + }, + defaultVariants: { + size: "sm", + tone: "default", + weight: "normal", + variant: "body", + truncate: false, + }, +}) + +function Text({ + className, + size, + tone, + weight, + variant, + truncate, + render, + ...props +}: useRender.ComponentProps<"p"> & VariantProps) { + return useRender({ + defaultTagName: "p", + props: mergeProps<"p">( + { + className: cn( + textVariants({ size, tone, weight, variant, truncate }), + className + ), + }, + props + ), + render, + state: { slot: "text", tone }, + }) +} + +const headingVariants = cva("tracking-tight text-foreground", { + variants: { + level: { + 1: "text-[1.375rem] font-semibold", + 2: "text-[0.9375rem] font-semibold", + 3: "text-[0.8125rem] font-semibold", + 4: "text-xs font-semibold", + }, + }, + defaultVariants: { level: 2 }, +}) + +type HeadingProps = useRender.ComponentProps<"h2"> & + VariantProps + +/** + * Section heading. `level` drives both the rendered tag and the type scale, so + * a page title is always `level={1}` and a card title `level={3}`. + */ +function Heading({ className, level = 2, render, ...props }: HeadingProps) { + return useRender({ + defaultTagName: `h${level ?? 2}`, + props: mergeProps<"h2">( + { className: cn(headingVariants({ level }), className) }, + props + ), + render, + state: { slot: "heading", level }, + }) +} + +export { Text, Heading, textVariants, headingVariants } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 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() +})
+ ) +} + +function TableRow({ + className, + interactive = true, + ...props +}: React.ComponentProps<"tr"> & { interactive?: boolean }) { + return ( +
+ ) +} + +/** Full-width row used to host an `EmptyState` inside a table body. */ +function TableEmptyRow({ + colSpan, + className, + children, + ...props +}: React.ComponentProps<"td"> & { colSpan: number }) { + return ( +
+ {children} +