@@ -124,7 +124,7 @@ export function DistributionsTable({ distributions, onClaim }: DistributionsTabl
)}
-
+
{row.txHash ? (
{row.txHash.slice(0, 8)}…
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 e663631..22f7533 100644
--- a/apps/web/src/features/earn/components/portfolio/assets-list.tsx
+++ b/apps/web/src/features/earn/components/portfolio/assets-list.tsx
@@ -66,7 +66,7 @@ function LoadingRows() {
-
+
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 f6cd57f..245b422 100644
--- a/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx
+++ b/apps/web/src/features/earn/components/portfolio/rewards-bar.tsx
@@ -149,7 +149,7 @@ export function RewardsBar() {
isLoading={isLoading}
/>
-
+
>([40])
const [announceCount, setAnnounceCount] = useState(0)
return (
-
-
Component Gallery
-
- Every packages/ui primitive, all variants. See{" "}
-
- DESIGN.md
- {" "}
- and{" "}
-
- packages/ui/CONTRIBUTING.md
- {" "}
- for how to add to this page.
-
+
+
+
setDirection(direction === "ltr" ? "rtl" : "ltr")}
+ className="shrink-0 rounded-md border border-border bg-card px-3 py-1.5 text-13 font-medium text-foreground transition-colors hover:bg-muted"
+ aria-label={`Switch to ${direction === "ltr" ? "right-to-left" : "left-to-right"} layout`}
+ >
+ {direction === "ltr" ? "LTR" : "RTL"}
+
diff --git a/apps/web/src/features/pools/components/gm-pool-row.tsx b/apps/web/src/features/pools/components/gm-pool-row.tsx
index 9d1e197..4f02842 100644
--- a/apps/web/src/features/pools/components/gm-pool-row.tsx
+++ b/apps/web/src/features/pools/components/gm-pool-row.tsx
@@ -46,7 +46,7 @@ function ValueCell({
className?: string
}) {
if (isLoading) {
- return
+ return
}
return (
@@ -207,7 +207,7 @@ export function GmPoolRow({ market, variant, onMetricsChange }: GmPoolRowProps)
-
+
{pendingTx ? (
-
+
{pendingTx.mode === "deposit" ? "Deposit pending" : "Withdrawal pending"}
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 692e6a7..8e3e09f 100644
--- a/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx
+++ b/apps/web/src/features/referrals/components/distributions/distributions-tab.tsx
@@ -97,13 +97,13 @@ export function DistributionsTab() {
-
+
Epoch
Date
Amount
Token
USD value
- Action
+ Action
@@ -118,7 +118,7 @@ export function DistributionsTab() {
{formatToken(d.amount, d.token)}
{d.token}
{formatUsd(d.amountUsd)}
-
+
-
+
Epoch
Date
Amount
diff --git a/apps/web/src/features/trade/components/TradePage.tsx b/apps/web/src/features/trade/components/TradePage.tsx
index d368599..5584a5a 100644
--- a/apps/web/src/features/trade/components/TradePage.tsx
+++ b/apps/web/src/features/trade/components/TradePage.tsx
@@ -67,7 +67,7 @@ export function TradePage() {
{/* ── Right: Trade Panel ─────────────────────────────────────── */}
-
diff --git a/apps/web/src/features/trade/components/chart/MarketSelector.tsx b/apps/web/src/features/trade/components/chart/MarketSelector.tsx
index e06c0de..d68fa23 100644
--- a/apps/web/src/features/trade/components/chart/MarketSelector.tsx
+++ b/apps/web/src/features/trade/components/chart/MarketSelector.tsx
@@ -40,7 +40,7 @@ function MarketRow({
role="option"
aria-selected={isActive}
tabIndex={-1}
- className={`flex w-full cursor-pointer items-center justify-between gap-4 rounded px-3 py-2 text-left text-sm transition-colors ${
+ className={`flex w-full cursor-pointer items-center justify-between gap-4 rounded px-3 py-2 text-start text-sm transition-colors ${
isActive ? "bg-accent/60" : "hover:bg-accent"
}`}
onClick={onSelect}
@@ -228,7 +228,7 @@ export function MarketSelector({ symbol, onSelect }: Props) {
)}
{open && (
-
+
Search markets
diff --git a/apps/web/src/features/trade/components/market-selector/MarketSelector.tsx b/apps/web/src/features/trade/components/market-selector/MarketSelector.tsx
index 29e269d..8f98dc0 100644
--- a/apps/web/src/features/trade/components/market-selector/MarketSelector.tsx
+++ b/apps/web/src/features/trade/components/market-selector/MarketSelector.tsx
@@ -67,7 +67,7 @@ export function MarketSelector({ markets, activeMarketId, onSelect }: Props) {
{open && (
-
+
diff --git a/apps/web/src/features/trade/components/positions/ClosePositionDialog.tsx b/apps/web/src/features/trade/components/positions/ClosePositionDialog.tsx
index 9518297..8f85640 100644
--- a/apps/web/src/features/trade/components/positions/ClosePositionDialog.tsx
+++ b/apps/web/src/features/trade/components/positions/ClosePositionDialog.tsx
@@ -114,9 +114,9 @@ export function ClosePositionDialog({ position, open, onClose, onConfirm }: Prop
placeholder="0.00"
value={partialUsd}
onChange={(e) => setPartialUsd(e.target.value)}
- className="pr-14 font-mono text-sm"
+ className="pe-14 font-mono text-sm"
/>
-
+
USD
diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
index 608ad63..013befd 100644
--- a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
+++ b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
@@ -188,9 +188,9 @@ export function CollateralDialog({ position, mode, open, onClose }: Props) {
value={amount}
disabled={isSubmitting}
onChange={(e) => setAmount(e.target.value)}
- className="pr-16 font-mono text-sm"
+ className="pe-16 font-mono text-sm"
/>
-
+
{tokenSymbol}
diff --git a/apps/web/src/features/trade/components/positions/OrderExecutionFrozenBanner.tsx b/apps/web/src/features/trade/components/positions/OrderExecutionFrozenBanner.tsx
index 403e40c..6529420 100644
--- a/apps/web/src/features/trade/components/positions/OrderExecutionFrozenBanner.tsx
+++ b/apps/web/src/features/trade/components/positions/OrderExecutionFrozenBanner.tsx
@@ -28,7 +28,7 @@ export function OrderExecutionFrozenBanner({ visible }: Props) {
Dismiss
diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
index 61fc8dd..5dd0a23 100644
--- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
@@ -256,7 +256,7 @@ export function ConfirmationDialog({
!v && onClose()}>
-
+
Confirm {typeLabel} {!tradeFlags.isSwap && formatAddress(toTokenAddress)}
diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx
index a2bcad7..588097b 100644
--- a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx
@@ -142,10 +142,10 @@ export function TradePanel({ trade }: TradePanelProps) {
setTriggerPrice(e.target.value)}
/>
-
+
USD
@@ -202,7 +202,7 @@ export function TradePanel({ trade }: TradePanelProps) {
step={0.1}
value={advanced.slippagePct}
onChange={(e) => trade.setSlippagePct(Number(e.target.value))}
- className="pr-10 font-mono text-sm"
+ className="pe-10 font-mono text-sm"
/>
%
diff --git a/apps/web/src/features/wallet/components/AccountBadge.tsx b/apps/web/src/features/wallet/components/AccountBadge.tsx
index cce3aa6..f9ff608 100644
--- a/apps/web/src/features/wallet/components/AccountBadge.tsx
+++ b/apps/web/src/features/wallet/components/AccountBadge.tsx
@@ -64,7 +64,7 @@ export function AccountBadge({ address, className, ...props }: AccountBadgeProps
}
return (
-
+
{open && (
-
+
diff --git a/apps/web/src/features/wallet/components/ConnectButton.tsx b/apps/web/src/features/wallet/components/ConnectButton.tsx
index a2e4032..9e69607 100644
--- a/apps/web/src/features/wallet/components/ConnectButton.tsx
+++ b/apps/web/src/features/wallet/components/ConnectButton.tsx
@@ -137,7 +137,7 @@ function AccountBadge({
{open && (
-
+
Connected
{address}
@@ -153,7 +153,7 @@ function AccountBadge({
{
navigator.clipboard.writeText(address)
setOpen(false)
@@ -167,7 +167,7 @@ function AccountBadge({
href="https://friendbot.stellar.org"
target="_blank"
rel="noopener noreferrer"
- className="block w-full rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
+ className="block w-full rounded px-2 py-1.5 text-start text-sm transition-colors hover:bg-accent"
>
Testnet Faucet
@@ -175,7 +175,7 @@ function AccountBadge({
{
disconnect()
setOpen(false)
diff --git a/apps/web/src/features/wallet/components/NetworkMismatchBanner.tsx b/apps/web/src/features/wallet/components/NetworkMismatchBanner.tsx
index 892e6a5..e677d5f 100644
--- a/apps/web/src/features/wallet/components/NetworkMismatchBanner.tsx
+++ b/apps/web/src/features/wallet/components/NetworkMismatchBanner.tsx
@@ -37,7 +37,7 @@ export function NetworkMismatchBanner() {
Dismiss
diff --git a/apps/web/src/shared/components/NumberInput.tsx b/apps/web/src/shared/components/NumberInput.tsx
index 0c54d38..41d9d6d 100644
--- a/apps/web/src/shared/components/NumberInput.tsx
+++ b/apps/web/src/shared/components/NumberInput.tsx
@@ -41,7 +41,7 @@ export function NumberInput({
placeholder={placeholder}
value={value}
onChange={handleChange}
- className={cn(onMax && "pr-14", className)}
+ className={cn(onMax && "pe-14", className)}
disabled={disabled}
{...props}
/>
@@ -50,7 +50,7 @@ export function NumberInput({
type="button"
size="sm"
variant="outline"
- className="absolute right-2 top-1/2 -translate-y-1/2 px-2 text-11"
+ className="absolute inset-inline-end-2 top-1/2 -translate-y-1/2 px-2 text-11"
onClick={onMax}
disabled={disabled}
>
diff --git a/apps/web/src/shared/components/toast/toast.tsx b/apps/web/src/shared/components/toast/toast.tsx
new file mode 100644
index 0000000..e3636c8
--- /dev/null
+++ b/apps/web/src/shared/components/toast/toast.tsx
@@ -0,0 +1,117 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ useRef,
+ useEffect,
+ type ReactNode,
+} from "react"
+import { cn } from "@workspace/ui/lib/utils"
+
+export type ToastVariant = "success" | "error" | "info"
+
+export type ToastItem = {
+ id: string
+ message: string
+ variant: ToastVariant
+ /** Auto-dismiss after this many ms. 0 = no auto-dismiss. */
+ duration: number
+}
+
+type ToastContextValue = {
+ toasts: ToastItem[]
+ show: (message: string, variant?: ToastVariant, duration?: number) => void
+ dismiss: (id: string) => void
+}
+
+const ToastContext = createContext(null)
+
+let _counter = 0
+function nextId() {
+ return `toast-${++_counter}`
+}
+
+const VARIANT_CLASSES: Record = {
+ success: "bg-green-900/90 text-green-100 border-green-700/50",
+ error: "bg-red-900/90 text-red-100 border-red-700/50",
+ info: "bg-slate-800/90 text-slate-100 border-slate-700/50",
+}
+
+const VARIANT_LABEL: Record = {
+ success: "Success",
+ error: "Error",
+ info: "Info",
+}
+
+const DEFAULT_DURATION = 4000
+
+function Toast({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
+ const timerRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (item.duration <= 0) return
+ timerRef.current = setTimeout(() => onDismiss(item.id), item.duration)
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [item.id, item.duration, onDismiss])
+
+ return (
+
+ {item.message}
+ onDismiss(item.id)}
+ className="shrink-0 opacity-70 hover:opacity-100"
+ >
+ ✕
+
+
+ )
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([])
+
+ const dismiss = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+ }, [])
+
+ const show = useCallback(
+ (message: string, variant: ToastVariant = "info", duration = DEFAULT_DURATION) => {
+ const item: ToastItem = { id: nextId(), message, variant, duration }
+ setToasts((prev) => [...prev, item])
+ },
+ [],
+ )
+
+ return (
+
+ {children}
+
+ {toasts.map((t) => (
+
+ ))}
+
+
+ )
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext)
+ if (!ctx) throw new Error("useToast must be used inside ")
+ return ctx
+}
diff --git a/apps/web/src/shared/lib/format.test.ts b/apps/web/src/shared/lib/format.test.ts
index f316123..8e9f087 100644
--- a/apps/web/src/shared/lib/format.test.ts
+++ b/apps/web/src/shared/lib/format.test.ts
@@ -1,63 +1,347 @@
import { describe, expect, it } from "vitest"
-import { formatToken } from "./format"
+import {
+ formatToken,
+ formatUsd,
+ formatPct,
+ formatCompact,
+ formatSmall,
+ formatAddress,
+ formatTxHash,
+ formatDate,
+ formatTime,
+ formatDateTime,
+ formatRelativeTime,
+} from "./format"
+
+// ─── Shared fallback ─────────────────────────────────────────────────────────
+
+describe("shared fallback for invalid values", () => {
+ it.each([
+ ["undefined", undefined],
+ ["null", null],
+ ["NaN", Number.NaN],
+ ["positive infinity", Number.POSITIVE_INFINITY],
+ ["negative infinity", Number.NEGATIVE_INFINITY],
+ ] as const)("returns — for %s", (_label, value) => {
+ expect(formatUsd(value)).toBe("—")
+ expect(formatToken(value, "USDC")).toBe("—")
+ expect(formatPct(value)).toBe("—")
+ expect(formatCompact(value)).toBe("—")
+ expect(formatSmall(value)).toBe("—")
+ })
+
+ it.each([
+ ["undefined", undefined],
+ ["null", null],
+ ["invalid date string", "not-a-date"],
+ ] as const)("returns — for %s date input", (_label, value) => {
+ expect(formatDate(value as unknown as Date)).toBe("—")
+ expect(formatTime(value as unknown as Date)).toBe("—")
+ expect(formatDateTime(value as unknown as Date)).toBe("—")
+ expect(formatRelativeTime(value as unknown as Date)).toBe("—")
+ })
+})
+
+// ─── Negative zero ────────────────────────────────────────────────────────────
+
+describe("negative zero normalization", () => {
+ it("formatUsd renders -0 as $0.00", () => {
+ expect(formatUsd(-0)).toBe("$0.00")
+ })
+
+ it("formatToken renders -0 as 0", () => {
+ expect(formatToken(-0, "USDC")).toBe("0 USDC")
+ })
+
+ it("formatPct renders -0 as +0.00%", () => {
+ expect(formatPct(-0)).toBe("+0.00%")
+ })
+
+ it("formatCompact renders -0 as 0", () => {
+ expect(formatCompact(-0)).toBe("0")
+ })
+
+ it("formatSmall renders -0 as 0", () => {
+ expect(formatSmall(-0)).toBe("0")
+ })
+})
+
+// ─── formatUsd ───────────────────────────────────────────────────────────────
+
+describe("formatUsd", () => {
+ it("formats a simple dollar amount", () => {
+ expect(formatUsd(12345.678)).toBe("$12,345.68")
+ })
+
+ it("formats zero", () => {
+ expect(formatUsd(0)).toBe("$0.00")
+ })
+
+ it("formats negative values", () => {
+ expect(formatUsd(-500)).toBe("-$500.00")
+ })
+
+ it("respects custom decimals", () => {
+ expect(formatUsd(1.5, { decimals: 4 })).toBe("$1.5000")
+ })
+
+ it("renders compact notation for large values", () => {
+ expect(formatUsd(1_200_000, { compact: true })).toBe("$1.2M")
+ expect(formatUsd(2_500_000_000, { compact: true })).toBe("$2.5B")
+ })
+
+ it("accepts a custom locale (de-DE uses . for grouping, , for decimals)", () => {
+ expect(formatUsd(12345.67, { locale: "de-DE" })).toBe("12.345,67\u00a0$")
+ })
+
+ it("accepts a custom currency code", () => {
+ expect(formatUsd(100, { currency: "EUR", locale: "de-DE" })).toBe("100,00\u00a0€")
+ })
+})
+
+// ─── formatCompact ───────────────────────────────────────────────────────────
+
+describe("formatCompact", () => {
+ it("formats thousands as K", () => {
+ expect(formatCompact(1_500)).toBe("1.5K")
+ })
+
+ it("formats millions as M", () => {
+ expect(formatCompact(2_300_000)).toBe("2.3M")
+ })
+
+ it("formats billions as B", () => {
+ expect(formatCompact(4_500_000_000)).toBe("4.5B")
+ })
+
+ it("returns plain number for values under 1000", () => {
+ expect(formatCompact(999)).toBe("999")
+ expect(formatCompact(12.5)).toBe("12.5")
+ })
+
+ it("respects decimal precision", () => {
+ expect(formatCompact(1_234_567, { decimals: 2 })).toBe("1.23M")
+ })
+
+ it("uses toFixed for compact B/M/K (locale-independent)", () => {
+ expect(formatCompact(1_500, { locale: "de-DE" })).toBe("1.5K")
+ })
+})
+
+// ─── formatToken ─────────────────────────────────────────────────────────────
describe("formatToken", () => {
- describe("fallback display for invalid amounts", () => {
- it.each([
- ["undefined amount", undefined],
- ["null amount", null],
- ["NaN amount", Number.NaN],
- ["positive infinity", Number.POSITIVE_INFINITY],
- ["negative infinity", Number.NEGATIVE_INFINITY],
- ] as const)("returns the shared fallback for %s", (_label, amount) => {
- expect(formatToken(amount, "USDC")).toBe("—")
- expect(formatToken(amount, "TWBTC")).toBe("—")
- })
- })
-
- describe("amount and symbol display", () => {
- it("places the token symbol after the formatted amount", () => {
- expect(formatToken(1.5, "TUSDC")).toBe("1.5 TUSDC")
- expect(formatToken(0.00432, "TWBTC")).toBe("0.0043 TWBTC")
- })
-
- it("formats zero balances for faucet-style token symbols", () => {
- expect(formatToken(0, "TXLM")).toBe("0 TXLM")
- expect(formatToken(0, "TUSDC", { decimals: 2 })).toBe("0 TUSDC")
- expect(formatToken(0, "TUSDC", { decimals: 2, minDecimals: 2 })).toBe("0.00 TUSDC")
- })
-
- it("formats typical faucet claim amounts with two decimal places", () => {
- expect(formatToken(250, "TUSDC", { decimals: 2 })).toBe("250 TUSDC")
- expect(formatToken(12.3, "TETH", { decimals: 2 })).toBe("12.3 TETH")
- expect(formatToken(12.345, "TUSDC", { decimals: 2 })).toBe("12.35 TUSDC")
- })
-
- it("formats trading balances with four decimal places by default", () => {
- expect(formatToken(0.123456, "TWBTC")).toBe("0.1235 TWBTC")
- expect(formatToken(42.9876, "TXLM")).toBe("42.9876 TXLM")
- })
-
- it("formats large balances with grouping separators", () => {
- expect(formatToken(1_234_567.8912, "USDC", { decimals: 4 })).toBe("1,234,567.8912 USDC")
- expect(formatToken(9_876_543.21, "WBTC", { decimals: 2 })).toBe("9,876,543.21 WBTC")
- })
- })
-
- describe("decimal precision options", () => {
- it("respects the maximum decimal precision option", () => {
- expect(formatToken(1234.56789, "USDC", { decimals: 2 })).toBe("1,234.57 USDC")
- expect(formatToken(0.00009, "BTC", { decimals: 4 })).toBe("0.0001 BTC")
- expect(formatToken(0.000001, "BTC", { decimals: 4 })).toBe("0 BTC")
- })
-
- it("pads amounts to the configured minimum decimal precision", () => {
- expect(formatToken(100, "esSO4", { minDecimals: 2 })).toBe("100.00 esSO4")
- expect(formatToken(5, "MP", { decimals: 4, minDecimals: 2 })).toBe("5.00 MP")
- })
-
- it("matches faucet balance formatting with four decimal places", () => {
- expect(formatToken(1234.5678, "TUSDC", { decimals: 4 })).toBe("1,234.5678 TUSDC")
- })
+ it("places the token symbol after the formatted amount", () => {
+ expect(formatToken(1.5, "TUSDC")).toBe("1.5 TUSDC")
+ expect(formatToken(0.00432, "TWBTC")).toBe("0.0043 TWBTC")
+ })
+
+ it("formats zero balances", () => {
+ expect(formatToken(0, "TXLM")).toBe("0 TXLM")
+ expect(formatToken(0, "TUSDC", { decimals: 2 })).toBe("0 TUSDC")
+ expect(formatToken(0, "TUSDC", { decimals: 2, minDecimals: 2 })).toBe("0.00 TUSDC")
+ })
+
+ it("formats large balances with grouping separators", () => {
+ expect(formatToken(1_234_567.8912, "USDC", { decimals: 4 })).toBe("1,234,567.8912 USDC")
+ })
+
+ it("formats with de-DE locale", () => {
+ expect(formatToken(1234.5, "BTC", { locale: "de-DE" })).toBe("1.234,5 BTC")
+ })
+
+ it("pads to minimum decimals", () => {
+ expect(formatToken(100, "esSO4", { minDecimals: 2 })).toBe("100.00 esSO4")
+ })
+})
+
+// ─── formatSmall ──────────────────────────────────────────────────────────────
+
+describe("formatSmall", () => {
+ it("renders normal values as-is", () => {
+ expect(formatSmall(1.5)).toBe("1.5")
+ expect(formatSmall(0.01)).toBe("0.01")
+ })
+
+ it("renders less-than for values below threshold", () => {
+ expect(formatSmall(0.00005)).toBe("<0.0001")
+ expect(formatSmall(0.00001)).toBe("<0.0001")
+ })
+
+ it("renders zero as 0", () => {
+ expect(formatSmall(0)).toBe("0")
+ })
+
+ it("respects custom threshold", () => {
+ expect(formatSmall(0.001, { threshold: 0.01 })).toBe("<0.0100")
+ expect(formatSmall(0.005, { threshold: 0.01 })).toBe("<0.0100")
+ })
+
+ it("shows values at or above threshold", () => {
+ expect(formatSmall(0.0001)).toBe("0.0001")
+ expect(formatSmall(0.01)).toBe("0.01")
+ })
+
+ it("formats with de-DE locale", () => {
+ expect(formatSmall(0.00005, { locale: "de-DE" })).toBe("<0,0001")
+ })
+})
+
+// ─── formatPct ───────────────────────────────────────────────────────────────
+
+describe("formatPct", () => {
+ it("formats positive value with + sign", () => {
+ expect(formatPct(1.23)).toBe("+1.23%")
+ })
+
+ it("formats negative value with - sign", () => {
+ expect(formatPct(-0.45)).toBe("-0.45%")
+ })
+
+ it("formats zero with + sign by default", () => {
+ expect(formatPct(0)).toBe("+0.00%")
+ })
+
+ it("hides sign when sign option is false", () => {
+ expect(formatPct(1.23, { sign: false })).toBe("1.23%")
+ expect(formatPct(-0.45, { sign: false })).toBe("-0.45%")
+ })
+
+ it("respects custom decimals", () => {
+ expect(formatPct(1.2345, { decimals: 3 })).toBe("+1.235%")
+ })
+})
+
+// ─── formatAddress ────────────────────────────────────────────────────────────
+
+describe("formatAddress", () => {
+ it("truncates a long Stellar address", () => {
+ expect(formatAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890WXYZ")).toBe("GABCDE…WXYZ")
+ })
+
+ it("returns the input unchanged for short strings", () => {
+ expect(formatAddress("short")).toBe("short")
+ })
+
+ it("returns fallback for empty string", () => {
+ expect(formatAddress("")).toBe("—")
+ })
+})
+
+// ─── formatTxHash ────────────────────────────────────────────────────────────
+
+describe("formatTxHash", () => {
+ it("truncates a transaction hash", () => {
+ expect(formatTxHash("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6")).toBe("a1b2c3d4…o5p6")
+ })
+
+ it("returns fallback for empty string", () => {
+ expect(formatTxHash("")).toBe("—")
+ })
+})
+
+// ─── formatDate ───────────────────────────────────────────────────────────────
+
+describe("formatDate", () => {
+ it("formats a date string", () => {
+ const result = formatDate("2026-07-29")
+ expect(result).toMatch(/Jul.*29.*2026/)
+ })
+
+ it("formats a Date object", () => {
+ const result = formatDate(new Date("2026-07-29"))
+ expect(result).toMatch(/Jul.*29.*2026/)
+ })
+
+ it("formats a timestamp", () => {
+ const ts = new Date("2026-07-29").getTime()
+ const result = formatDate(ts)
+ expect(result).toMatch(/Jul.*29.*2026/)
+ })
+
+ it("respects locale (de-DE uses day.month.year)", () => {
+ expect(formatDate("2026-07-29", { locale: "de-DE" })).toBe("29.7.2026")
+ })
+
+ it("respects custom month format", () => {
+ expect(formatDate("2026-07-29", { month: "long" })).toContain("July")
+ })
+})
+
+// ─── formatTime ───────────────────────────────────────────────────────────────
+
+describe("formatTime", () => {
+ it("formats a time", () => {
+ const result = formatTime("2026-07-29T14:30:00Z")
+ expect(result).toBeTruthy()
+ })
+
+ it("shows timezone name when requested", () => {
+ const result = formatTime("2026-07-29T14:30:00Z", { timeZone: "UTC", timeZoneName: "short" })
+ expect(result).toContain("UTC")
+ })
+
+ it("includes seconds when requested", () => {
+ const result = formatTime("2026-07-29T14:30:15Z", { second: "2-digit", timeZone: "UTC" })
+ expect(result).toContain("15")
+ })
+})
+
+// ─── formatDateTime ──────────────────────────────────────────────────────────
+
+describe("formatDateTime", () => {
+ it("combines date and time", () => {
+ const result = formatDateTime("2026-07-29T14:30:00Z")
+ expect(result).toBeTruthy()
+ })
+
+ it("respects locale", () => {
+ const result = formatDateTime("2026-07-29T14:30:00Z", { locale: "de-DE", timeZone: "UTC" })
+ expect(result).toContain("29")
+ expect(result).toContain("7")
+ expect(result).toContain("2026")
})
})
+
+// ─── formatRelativeTime ───────────────────────────────────────────────────────
+
+describe("formatRelativeTime", () => {
+ it("formats future dates", () => {
+ const future = Date.now() + 7200_000
+ const result = formatRelativeTime(new Date(future))
+ expect(result).toContain("2")
+ })
+
+ it("formats past dates", () => {
+ const past = Date.now() - 7200_000
+ const result = formatRelativeTime(new Date(past))
+ expect(result).toContain("2")
+ })
+
+ it("formats recent dates as 'now'", () => {
+ const result = formatRelativeTime(new Date())
+ expect(result).toBeTruthy()
+ })
+
+ it("respects locale (de-DE)", () => {
+ const past = Date.now() - 3600_000
+ const result = formatRelativeTime(new Date(past), { locale: "de-DE" })
+ expect(result).toBeTruthy()
+ })
+})
+
+// ─── Large values ────────────────────────────────────────────────────────────
+
+describe("large values with grouping separators", () => {
+ it("formatUsd groups thousands", () => {
+ expect(formatUsd(1_234_567.89)).toBe("$1,234,567.89")
+ })
+
+ it("formatToken groups thousands", () => {
+ expect(formatToken(9_876_543.21, "WBTC", { decimals: 2 })).toBe("9,876,543.21 WBTC")
+ })
+
+ it("formatUsd groups with de-DE locale", () => {
+ expect(formatUsd(1_234_567.89, { locale: "de-DE" })).toBe("1.234.567,89\u00a0$")
+ })
+})
\ No newline at end of file
diff --git a/apps/web/src/shared/lib/format.ts b/apps/web/src/shared/lib/format.ts
index 9a9fdb6..2c4cee4 100644
--- a/apps/web/src/shared/lib/format.ts
+++ b/apps/web/src/shared/lib/format.ts
@@ -1,60 +1,88 @@
-// Centralised number / currency / address formatting
+// Centralised number / currency / address / date formatting
// Every feature should import from here instead of defining inline helpers.
// ─── Guard ───────────────────────────────────────────────────────────────────
const FALLBACK = "—"
+const SMALL_THRESHOLD = 0.0001
function isBad(n: number | undefined | null): n is undefined | null {
return n == null || !isFinite(n)
}
-// ─── USD ─────────────────────────────────────────────────────────────────────
+function normalize(n: number): number {
+ return Object.is(n, -0) ? 0 : n
+}
+
+const DEFAULT_LOCALE = "en-US"
+
+// ─── USD / Currency ─────────────────────────────────────────────────────────
-type FormatUsdOpts = {
- /** Fraction digits (default 2) */
+type FormatCurrencyOpts = {
+ locale?: string
+ currency?: string
decimals?: number
- /** Use compact notation, e.g. "$1.2M" (default false) */
compact?: boolean
}
-/**
- * Format a number as USD currency.
- *
- * @example formatUsd(12345.678) // "$12,345.68"
- * @example formatUsd(1_200_000, { compact: true }) // "$1.2M"
- * @example formatUsd(NaN) // "—"
- */
-export function formatUsd(n: number | undefined | null, opts?: FormatUsdOpts): string {
+export function formatUsd(
+ n: number | undefined | null,
+ opts?: FormatCurrencyOpts,
+): string {
if (isBad(n)) return FALLBACK
- const { decimals = 2, compact = false } = opts ?? {}
+ const { locale = DEFAULT_LOCALE, currency = "USD", decimals = 2, compact = false } = opts ?? {}
+ const value = normalize(n)
- return new Intl.NumberFormat("en-US", {
+ return new Intl.NumberFormat(locale, {
style: "currency",
- currency: "USD",
+ currency,
minimumFractionDigits: compact ? 0 : decimals,
maximumFractionDigits: decimals,
...(compact ? { notation: "compact" as const } : {}),
- }).format(n)
+ }).format(value)
+}
+
+// ─── Compact number ──────────────────────────────────────────────────────────
+
+type FormatCompactOpts = {
+ locale?: string
+ decimals?: number
+}
+
+export function formatCompact(
+ n: number | undefined | null,
+ opts?: FormatCompactOpts,
+): string {
+ if (isBad(n)) return FALLBACK
+
+ const { locale = DEFAULT_LOCALE, decimals = 1 } = opts ?? {}
+ const value = normalize(n)
+
+ if (Math.abs(value) >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(decimals)}B`
+ }
+ if (Math.abs(value) >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(decimals)}M`
+ }
+ if (Math.abs(value) >= 1_000) {
+ return `${(value / 1_000).toFixed(decimals)}K`
+ }
+
+ return value.toLocaleString(locale, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: decimals,
+ })
}
// ─── Token ───────────────────────────────────────────────────────────────────
type FormatTokenOpts = {
- /** Max fraction digits (default 4) */
+ locale?: string
decimals?: number
- /** Min fraction digits (default 0) */
minDecimals?: number
}
-/**
- * Format a token amount with its symbol.
- *
- * @example formatToken(0.00432, "BTC") // "0.0043 BTC"
- * @example formatToken(1234.5, "USDC", { decimals: 2 }) // "1,234.50 USDC"
- * @example formatToken(undefined, "ETH") // "—"
- */
export function formatToken(
n: number | undefined | null,
symbol: string,
@@ -62,9 +90,10 @@ export function formatToken(
): string {
if (isBad(n)) return FALLBACK
- const { decimals = 4, minDecimals = 0 } = opts ?? {}
+ const { locale = DEFAULT_LOCALE, decimals = 4, minDecimals = 0 } = opts ?? {}
+ const value = normalize(n)
- const formatted = n.toLocaleString("en-US", {
+ const formatted = value.toLocaleString(locale, {
minimumFractionDigits: minDecimals,
maximumFractionDigits: decimals,
})
@@ -72,39 +101,216 @@ export function formatToken(
return `${formatted} ${symbol}`
}
+// ─── Small values ────────────────────────────────────────────────────────────
+
+type FormatSmallOpts = {
+ locale?: string
+ decimals?: number
+ threshold?: number
+}
+
+export function formatSmall(
+ n: number | undefined | null,
+ opts?: FormatSmallOpts,
+): string {
+ if (isBad(n)) return FALLBACK
+
+ const { locale = DEFAULT_LOCALE, decimals = 4, threshold = SMALL_THRESHOLD } = opts ?? {}
+ const value = normalize(n)
+
+ if (value !== 0 && Math.abs(value) < threshold) {
+ const minStr = threshold.toLocaleString(locale, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ })
+ return `<${minStr}`
+ }
+
+ return value.toLocaleString(locale, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: decimals,
+ })
+}
+
// ─── Percentage ──────────────────────────────────────────────────────────────
type FormatPctOpts = {
- /** Fraction digits (default 2) */
decimals?: number
- /** Prepend +/- sign (default true) */
sign?: boolean
}
-/**
- * Format a percentage value.
- *
- * @example formatPct(1.23) // "+1.23%"
- * @example formatPct(-0.45) // "-0.45%"
- * @example formatPct(0) // "+0.00%"
- * @example formatPct(NaN) // "—"
- */
-export function formatPct(n: number | undefined | null, opts?: FormatPctOpts): string {
+export function formatPct(
+ n: number | undefined | null,
+ opts?: FormatPctOpts,
+): string {
if (isBad(n)) return FALLBACK
const { decimals = 2, sign = true } = opts ?? {}
- const prefix = sign ? (n >= 0 ? "+" : "") : ""
+ const value = normalize(n)
+ const prefix = sign ? (value >= 0 ? "+" : "") : ""
+
+ return `${prefix}${value.toFixed(decimals)}%`
+}
+
+// ─── Date / Time ─────────────────────────────────────────────────────────────
- return `${prefix}${n.toFixed(decimals)}%`
+type FormatDateOpts = {
+ locale?: string
+ year?: "numeric" | "2-digit"
+ month?: "numeric" | "2-digit" | "short" | "long"
+ day?: "numeric" | "2-digit"
+ timeZone?: string
+}
+
+export function formatDate(
+ date: Date | string | number | undefined | null,
+ opts?: FormatDateOpts,
+): string {
+ if (date == null) return FALLBACK
+
+ const d = date instanceof Date ? date : new Date(date)
+ if (Number.isNaN(d.getTime())) return FALLBACK
+
+ const {
+ locale = DEFAULT_LOCALE,
+ year = "numeric",
+ month = "short",
+ day = "numeric",
+ timeZone,
+ } = opts ?? {}
+
+ return new Intl.DateTimeFormat(locale, {
+ year,
+ month,
+ day,
+ timeZone,
+ }).format(d)
+}
+
+type FormatTimeOpts = {
+ locale?: string
+ hour?: "numeric" | "2-digit"
+ minute?: "2-digit"
+ second?: "2-digit"
+ timeZone?: string
+ timeZoneName?: "short" | "long"
+}
+
+export function formatTime(
+ date: Date | string | number | undefined | null,
+ opts?: FormatTimeOpts,
+): string {
+ if (date == null) return FALLBACK
+
+ const d = date instanceof Date ? date : new Date(date)
+ if (Number.isNaN(d.getTime())) return FALLBACK
+
+ const {
+ locale = DEFAULT_LOCALE,
+ hour = "2-digit",
+ minute = "2-digit",
+ second,
+ timeZone,
+ timeZoneName,
+ } = opts ?? {}
+
+ return new Intl.DateTimeFormat(locale, {
+ hour,
+ minute,
+ ...(second != null ? { second } : {}),
+ ...(timeZone != null ? { timeZone } : {}),
+ ...(timeZoneName != null ? { timeZoneName } : {}),
+ }).format(d)
+}
+
+type FormatDateTimeOpts = FormatDateOpts & FormatTimeOpts
+
+export function formatDateTime(
+ date: Date | string | number | undefined | null,
+ opts?: FormatDateTimeOpts,
+): string {
+ if (date == null) return FALLBACK
+
+ const d = date instanceof Date ? date : new Date(date)
+ if (Number.isNaN(d.getTime())) return FALLBACK
+
+ const {
+ locale = DEFAULT_LOCALE,
+ year = "numeric",
+ month = "short",
+ day = "numeric",
+ hour = "2-digit",
+ minute = "2-digit",
+ second,
+ timeZone,
+ timeZoneName,
+ } = opts ?? {}
+
+ return new Intl.DateTimeFormat(locale, {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ ...(second != null ? { second } : {}),
+ ...(timeZone != null ? { timeZone } : {}),
+ ...(timeZoneName != null ? { timeZoneName } : {}),
+ }).format(d)
+}
+
+// ─── Relative time ───────────────────────────────────────────────────────────
+
+type RelativeTimeUnit = "year" | "quarter" | "month" | "week" | "day" | "hour" | "minute" | "second"
+
+type FormatRelativeTimeOpts = {
+ locale?: string
+ numeric?: "always" | "auto"
+ style?: "long" | "short" | "narrow"
+}
+
+const RELATIVE_UNIT_LIMITS: [number, RelativeTimeUnit][] = [
+ [60, "second"],
+ [3600, "minute"],
+ [86400, "hour"],
+ [2592000, "day"],
+ [31536000, "month"],
+ [Infinity, "year"],
+]
+
+function closestUnit(seconds: number): { value: number; unit: RelativeTimeUnit } {
+ const abs = Math.abs(seconds)
+ for (const [limit, unit] of RELATIVE_UNIT_LIMITS) {
+ if (abs < limit) {
+ const value = unit === "second" ? Math.round(seconds) : Math.floor(seconds / (limit / (unit === "minute" ? 60 : 1)))
+ return { value, unit }
+ }
+ }
+ return { value: Math.floor(seconds / 31536000), unit: "year" }
+}
+
+export function formatRelativeTime(
+ date: Date | string | number | undefined | null,
+ opts?: FormatRelativeTimeOpts,
+): string {
+ if (date == null) return FALLBACK
+
+ const d = date instanceof Date ? date : new Date(date)
+ if (Number.isNaN(d.getTime())) return FALLBACK
+
+ const { locale = DEFAULT_LOCALE, numeric = "auto", style = "short" } = opts ?? {}
+
+ const now = Date.now()
+ const diffMs = d.getTime() - now
+ const diffSec = Math.round(diffMs / 1000)
+
+ const { value, unit } = closestUnit(diffSec)
+
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric, style })
+ return rtf.format(value, unit)
}
// ─── Address ─────────────────────────────────────────────────────────────────
-/**
- * Truncate a Stellar address for display.
- *
- * @example formatAddress("GABC...full...XYZ") // "GABCDE…WXYZ"
- */
export function formatAddress(addr: string): string {
if (!addr || addr.length < 12) return addr || FALLBACK
return `${addr.slice(0, 6)}…${addr.slice(-4)}`
@@ -112,12 +318,7 @@ export function formatAddress(addr: string): string {
// ─── Tx Hash ─────────────────────────────────────────────────────────────────
-/**
- * Truncate a transaction hash for display (first 8 + last 4 chars).
- *
- * @example formatTxHash("a1b2c3d4e5f6g7h8i9j0") // "a1b2c3d4…j9j0"
- */
export function formatTxHash(hash: string): string {
if (!hash || hash.length < 14) return hash || FALLBACK
return `${hash.slice(0, 8)}…${hash.slice(-4)}`
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/ui/connect-button.tsx b/apps/web/src/ui/connect-button.tsx
index 367b941..12e8e38 100644
--- a/apps/web/src/ui/connect-button.tsx
+++ b/apps/web/src/ui/connect-button.tsx
@@ -67,7 +67,7 @@ export function ConnectButton({
aria-label="Connecting wallet"
>
Connecting
@@ -90,7 +90,7 @@ export function ConnectButton({
aria-expanded={open}
aria-controls={open ? dropdownId : undefined}
>
-
+
{shortenAddress(address)}
@@ -147,7 +147,7 @@ export function ConnectButton({
items[currentIndex]?.click()
}
}}
- className="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border border-border bg-background py-2 shadow-xl sm:absolute sm:top-full sm:right-0 sm:mt-1 sm:w-44 sm:rounded-lg"
+ className="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border border-border bg-background py-2 shadow-xl sm:absolute sm:top-full sm:inset-inline-end-0 sm:mt-1 sm:w-44 sm:rounded-lg"
>
@@ -164,7 +164,7 @@ export function ConnectButton({
void navigator.clipboard.writeText(address)
setOpen(false)
}}
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
+ className="flex w-full items-center gap-2 px-3 py-2 text-start text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
Copy address
@@ -178,7 +178,7 @@ export function ConnectButton({
disconnect()
setOpen(false)
}}
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
+ className="flex w-full items-center gap-2 px-3 py-2 text-start text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
Disconnect
diff --git a/apps/web/src/ui/direction-provider.tsx b/apps/web/src/ui/direction-provider.tsx
new file mode 100644
index 0000000..e3f50e6
--- /dev/null
+++ b/apps/web/src/ui/direction-provider.tsx
@@ -0,0 +1,38 @@
+import { createContext, useContext, useState } from "react"
+import type { ReactNode } from "react"
+
+export type Direction = "ltr" | "rtl"
+
+interface DirectionContextValue {
+ direction: Direction
+ setDirection: (direction: Direction) => void
+}
+
+const DirectionContext = createContext(undefined)
+
+const STORAGE_KEY = "so4-direction"
+
+export function DirectionProvider({ children }: { children: ReactNode }) {
+ const [direction, setDirectionState] = useState(() => {
+ if (typeof window === "undefined") return "ltr"
+ return (localStorage.getItem(STORAGE_KEY) as Direction | null) ?? "ltr"
+ })
+
+ const setDirection = (next: Direction) => {
+ if (typeof window !== "undefined") localStorage.setItem(STORAGE_KEY, next)
+ setDirectionState(next)
+ document.documentElement.dir = next
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useDirection() {
+ const ctx = useContext(DirectionContext)
+ if (!ctx) throw new Error("useDirection must be used within DirectionProvider")
+ return ctx
+}
\ No newline at end of file
diff --git a/apps/web/src/ui/landing/hero.tsx b/apps/web/src/ui/landing/hero.tsx
index 30ca46c..42926a2 100644
--- a/apps/web/src/ui/landing/hero.tsx
+++ b/apps/web/src/ui/landing/hero.tsx
@@ -32,7 +32,7 @@ function OrderBook() {
{ASKS.map((row) => (
{row.price}
@@ -52,7 +52,7 @@ function OrderBook() {
{BIDS.map((row) => (
{row.price}
@@ -69,12 +69,12 @@ function MiniChart({ priceLabel }: { priceLabel: string }) {
return (
{/* Live price tag */}
-
+
{priceLabel}
{/* Y-axis labels */}
-
+
67.4k
67.0k
66.6k
@@ -149,7 +149,7 @@ function MiniChart({ priceLabel }: { priceLabel: string }) {
{/* X-axis labels */}
-
+
09:00
11:00
13:00
@@ -189,7 +189,7 @@ function TradingCard() {
- BTC-PERP
+ BTC-PERP
${fmt}
diff --git a/apps/web/src/ui/landing/infrastructure.tsx b/apps/web/src/ui/landing/infrastructure.tsx
index fbc397d..c1fa3af 100644
--- a/apps/web/src/ui/landing/infrastructure.tsx
+++ b/apps/web/src/ui/landing/infrastructure.tsx
@@ -54,8 +54,8 @@ export function Infrastructure() {
{key}
@@ -63,7 +63,7 @@ export function Infrastructure() {
{val}
{unit && (
- {unit}
+ {unit}
)}
{desc}
diff --git a/apps/web/src/ui/landing/markets.tsx b/apps/web/src/ui/landing/markets.tsx
index 9706038..bed3c88 100644
--- a/apps/web/src/ui/landing/markets.tsx
+++ b/apps/web/src/ui/landing/markets.tsx
@@ -197,7 +197,7 @@ export function Markets() {
{oi}
-
+
Trade →
diff --git a/apps/web/src/ui/landing/stats.tsx b/apps/web/src/ui/landing/stats.tsx
index 58d6277..6357d51 100644
--- a/apps/web/src/ui/landing/stats.tsx
+++ b/apps/web/src/ui/landing/stats.tsx
@@ -43,9 +43,9 @@ export function Stats() {
key={label}
className={`relative p-7 sm:p-8 ${
i < STATS.length - 1
- ? "border-b border-border lg:border-b-0 lg:border-r"
+ ? "border-b border-border lg:border-b-0 lg:border-inline-end"
: ""
- } ${i % 2 === 0 && i < 2 ? "max-lg:border-r" : ""}`}
+ } ${i % 2 === 0 && i < 2 ? "max-lg:border-inline-end" : ""}`}
>
diff --git a/apps/web/src/ui/landing/ticker.tsx b/apps/web/src/ui/landing/ticker.tsx
index 540f6aa..2e08c68 100644
--- a/apps/web/src/ui/landing/ticker.tsx
+++ b/apps/web/src/ui/landing/ticker.tsx
@@ -17,7 +17,7 @@ const TICKERS = [
function TickerItem({ sym, px, ch, dir }: { sym: string; px: string; ch: string; dir: string }) {
return (
-
+
{sym}
${px}
diff --git a/e2e/design-system-visual.spec.ts b/e2e/design-system-visual.spec.ts
index f022972..1deba96 100644
--- a/e2e/design-system-visual.spec.ts
+++ b/e2e/design-system-visual.spec.ts
@@ -31,6 +31,7 @@ const VIEWPORTS = {
} as const
const THEMES = ["light", "dark"] as const
+const DIRECTIONS = ["ltr", "rtl"] as const
const ROUTES = [
{ name: "gallery", path: "/gallery" },
@@ -63,8 +64,6 @@ for (const theme of THEMES) {
test.beforeEach(async ({ page }) => {
await stubExternalNetwork(page)
- // Set localStorage before the app's own THEME_SCRIPT runs, so the
- // correct theme class is present from first paint.
await page.addInitScript((t) => {
window.localStorage.setItem("so4-theme", t)
}, theme)
@@ -84,3 +83,33 @@ for (const theme of THEMES) {
})
}
}
+
+// RTL gallery fixtures — the gallery has a direction toggle, so take
+// screenshots in both directions to verify RTL layout correctness.
+for (const theme of THEMES) {
+ for (const direction of DIRECTIONS) {
+ for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) {
+ test.describe(`${theme} theme, ${direction}, ${viewportName}`, () => {
+ test.use({ viewport })
+
+ test.beforeEach(async ({ page }) => {
+ await stubExternalNetwork(page)
+ await page.addInitScript(({ t, d }: { t: string; d: string }) => {
+ window.localStorage.setItem("so4-theme", t)
+ window.localStorage.setItem("so4-direction", d)
+ }, { t: theme, d: direction })
+ })
+
+ test(`gallery`, async ({ page }) => {
+ await page.goto("/gallery")
+ await page.waitForLoadState("networkidle")
+
+ await expect(page).toHaveScreenshot(`gallery-${theme}-${direction}-${viewportName}.png`, {
+ fullPage: true,
+ animations: "disabled",
+ })
+ })
+ })
+ }
+ }
+}
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
new file mode 100644
index 0000000..bd08979
--- /dev/null
+++ b/packages/ui/CHANGELOG.md
@@ -0,0 +1,48 @@
+# Changelog
+
+All notable changes to `@workspace/ui` — the SO4 design system — are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+### Changed
+
+### Deprecated
+
+### Removed
+
+### Fixed
+
+### Accessibility
+
+---
+
+## [0.0.0] — 2026-07-29 — Baseline
+
+Initial release of the SO4 design system, extracted and standardized from the existing SO4 Markets application.
+
+### Added (Baseline)
+
+- **Surface tokens**: five explicit surface layers — `canvas`, `sunken`, `raised`, `overlay`, `interactive` — replacing the generic shadcn page/card/popover pattern.
+- **Text tokens**: six text roles — `primary`, `secondary`, `tertiary`, `disabled`, `inverse`, `link`.
+- **Trading-state colours**: foreground/subtle/border triplets for `long`, `short`, `liquidation`.
+- **Semantic-status colours**: foreground/subtle/border triplets for `success`, `warning`, `info`, `danger`, `neutral`. Colour is never the only signal — shape, position, or label must accompany it.
+- **Micro-typography scale**: 16 font-size tokens between 9.5px and 40px (`text-9-5` through `text-40`) for the app's dense data-table-heavy UI.
+- **Radius scale**: `--radius-sm` through `--radius-4xl` derived from a single `--radius` root via `calc()`.
+- **Components** (60+): `alert`, `app-shell`, `avatar`, `badge`, `breadcrumb`, `button`, `card`, `checkbox`, `data-table`, `dialog`, `dropdown-menu`, `empty-state`, `field`, `filter-chip`, `icon-button`, `input`, `keyboard-shortcut`, `loading-button`, `numeric`, `page-header`, `popover`, `progress-indicator`, `radio-group`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `slider`, `spinner`, `stat`, `states`, `status-badge`, `switch`, `table`, `table-toolbar`, `tabs`, `text`, `textarea`, `token-avatar`, `tooltip`, `transaction-status`.
+- **Design-token check**: `scripts/check-design-tokens.ts` enforces token usage in CI — flags raw hex colours, arbitrary font sizes, and arbitrary radius classes.
+- **Visual regression suite**: Playwright spec (`e2e/design-system-visual.spec.ts`) screenshots `/gallery` and all main routes at desktop and mobile widths in both themes.
+- **Component gallery**: `/gallery` route rendering every component variant on one page for easy review.
+- **Geist Mono**: available for monospaced contexts (order books, addresses, code).
+- **Base UI primitives**: `@base-ui/react` as the accessibility foundation for all interactive components.
+
+### Deprecated
+
+- **shadcn aliases** (`--background`, `--card`, `--popover`): remain for backwards compatibility during migration. Prefer the surface-layer tokens (`--surface-*`) in new code.
+
+### Fixed
+
+- **DS-050 audit**: resolved 268 arbitrary-value violations (229 arbitrary font sizes, 38 raw hex colours, 1 arbitrary radius) across ~40 files — see [`DESIGN.md`](./DESIGN.md#audit-history) for details.
\ No newline at end of file
diff --git a/packages/ui/CONTRIBUTING.md b/packages/ui/CONTRIBUTING.md
index 52b15f1..f2f8d36 100644
--- a/packages/ui/CONTRIBUTING.md
+++ b/packages/ui/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to `@workspace/ui`
-`packages/ui` is the shared component library consumed by `apps/web` (and any future app in this monorepo) via the `@workspace/ui/*` import alias. This guide covers adding, testing, documenting, and exporting a component here — see the root [`CONTRIBUTING.md`](../../CONTRIBUTING.md) for the general repo workflow (branch naming, commit format, PR checklist).
+`packages/ui` is the shared component library consumed by `apps/web` (and any future app in this monorepo) via the `@workspace/ui/*` import alias. This guide covers adding, testing, documenting, and exporting a component here — see the [design-system PR checklist](../../.github/PULL_REQUEST_TEMPLATE/design-system.md) for the pull-request requirements specific to token and shared-component changes.
## Does this belong in `packages/ui`, or in a feature?
@@ -31,6 +31,7 @@ If you're unsure, default to the feature directory — it's a much smaller chang
then add a `"test": "vitest run"` script to this package's `package.json` and a `component-name.test.tsx` alongside the component, following the render/assert patterns already used in `apps/web/src/**/*.test.tsx`.
5. **Export**: add both the component and its prop-types export to whichever consumer imports it via `@workspace/ui/components/` — no central barrel file exists (each component is its own subpath export per `package.json`'s `exports` map), so there's no index to update.
6. **Document in the gallery**: add your component to [`apps/web/src/features/gallery/components/gallery-page.tsx`](../../apps/web/src/features/gallery/components/gallery-page.tsx), rendering every variant/size it supports. This is how reviewers and future contributors discover what exists and see every state at a glance — see [`DESIGN.md`](../../DESIGN.md#the-component-gallery-gallery) for why it exists.
+7. **Log in the changelog**: if the change is user-observable (new component, API change, deprecation, accessibility fix, token addition), add an entry to [`CHANGELOG.md`](./CHANGELOG.md) under the correct section — see the file's header for the expected sections. Breaking changes require a `### Migration note` subsection.
## Commands
@@ -43,7 +44,11 @@ bun run check:tokens # design-token usage check (whole re
bun run test:e2e -- design-system-visual # visual regression, once your component is in the gallery
```
-All four should be clean before opening a PR that touches this package — the same bar as the root [`CONTRIBUTING.md`](../../CONTRIBUTING.md) sets for the rest of the repo.
+All four should be clean before opening a PR that touches this package.
+
+## Pull request
+
+Before opening or requesting review on a PR that touches `packages/ui` or the design tokens, run through the [design-system PR checklist](../../.github/PULL_REQUEST_TEMPLATE/design-system.md). It covers theme verification, keyboard accessibility, mobile layout, visual regression snapshots, API compatibility, the token check, and changelog entry requirements.
## Further reading
diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx
index f7205db..c70e824 100644
--- a/packages/ui/src/components/alert.tsx
+++ b/packages/ui/src/components/alert.tsx
@@ -297,7 +297,7 @@ function Alert({
aria-label={dismissLabel}
onClick={onDismiss}
className={cn(
- "ml-auto shrink-0 self-start rounded-sm p-0.5 opacity-60",
+ "ms-auto shrink-0 self-start rounded-sm p-0.5 opacity-60",
"transition-opacity hover:opacity-100",
"focus-visible:ring-2 focus-visible:ring-current/40 focus-visible:outline-none"
)}
diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx
index 34d44e4..1b1fa3c 100644
--- a/packages/ui/src/components/badge.tsx
+++ b/packages/ui/src/components/badge.tsx
@@ -6,7 +6,7 @@ import { cn } from "@workspace/ui/lib/utils"
import type { VariantProps } from "class-variance-authority"
const badgeVariants = cva(
- "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
+ "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
{
variants: {
variant: {
@@ -31,7 +31,7 @@ const badgeVariants = cva(
danger:
"bg-danger/10 text-danger focus-visible:ring-danger/20 dark:bg-danger/20 dark:focus-visible:ring-danger/40 [a]:hover:bg-danger/20",
long:
- "bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 [a]:hover:bg-neutral/20 px-3 py-1 has-data-[slot=badge-icon]:pl-2",
+ "bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 [a]:hover:bg-neutral/20 px-3 py-1 has-data-[slot=badge-icon]:ps-2",
short:
"bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 size-2.5 min-w-0 p-0 justify-center [&>svg]:size-2 [&>svg]:mx-auto",
},
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 47d8cf7..2f70c55 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -23,10 +23,10 @@ const buttonVariants = cva(
},
size: {
default:
- "h-default gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- xs: "h-compact gap-1 rounded-sm px-2 text-10 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
- sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
+ "h-default gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ xs: "h-compact gap-1 rounded-sm px-2 text-10 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-2.5",
+ sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
+ lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
diff --git a/packages/ui/src/components/data-table.test.tsx b/packages/ui/src/components/data-table.test.tsx
new file mode 100644
index 0000000..3e2f4f6
--- /dev/null
+++ b/packages/ui/src/components/data-table.test.tsx
@@ -0,0 +1,162 @@
+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 { DataTable } from "./data-table"
+import type { Column } from "./data-table"
+
+interface TestRow {
+ id: string
+ name: string
+ value: number
+}
+
+const columns: Array> = [
+ { id: "name", header: "Name", accessor: (r) => r.name },
+ { id: "value", header: "Value", accessor: (r) => r.value, align: "right" },
+]
+
+const data: Array = [
+ { id: "1", name: "Alpha", value: 100 },
+ { id: "2", name: "Beta", value: 200 },
+]
+
+describe("DataTable", () => {
+ it("renders column headers and data rows", () => {
+ render(
+ r.id}
+ />
+ )
+ expect(screen.getByRole("columnheader", { name: "Name" })).toBeInTheDocument()
+ expect(screen.getByRole("columnheader", { name: "Value" })).toBeInTheDocument()
+ expect(screen.getByRole("cell", { name: "Alpha" })).toBeInTheDocument()
+ expect(screen.getByRole("cell", { name: "100" })).toBeInTheDocument()
+ })
+
+ it("applies right alignment to column header and cells", () => {
+ render(
+ r.id}
+ />
+ )
+ const th = screen.getByRole("columnheader", { name: "Value" })
+ const td = screen.getByRole("cell", { name: "100" })
+ expect(th).toHaveClass("text-right")
+ expect(td).toHaveClass("text-right")
+ })
+
+ it("renders skeleton rows while loading", () => {
+ const { container } = render(
+ r.id}
+ />
+ )
+ const skeletons = container.querySelectorAll('[data-slot="skeleton"]')
+ expect(skeletons.length).toBe(columns.length * 3)
+ })
+
+ it("renders empty state with message", () => {
+ render(
+ r.id}
+ />
+ )
+ expect(screen.getByText("No items found")).toBeInTheDocument()
+ })
+
+ it("renders empty state with action", () => {
+ render(
+ Add item}
+ keyExtractor={(r) => r.id}
+ />
+ )
+ expect(screen.getByRole("button", { name: "Add item" })).toBeInTheDocument()
+ })
+
+ it("highlights selected row", () => {
+ render(
+ r.id}
+ />
+ )
+ const cells = screen.getAllByRole("cell")
+ const alphaCell = cells.find((c) => c.textContent === "Alpha")!
+ const row = alphaCell.closest("tr")
+ expect(row).toHaveAttribute("aria-current", "true")
+ expect(row).toHaveClass("bg-muted/30")
+ })
+
+ it("calls onRowClick when a row is clicked", async () => {
+ const onRowClick = vi.fn()
+ const user = userEvent.setup()
+ render(
+ r.id}
+ onRowClick={onRowClick}
+ />
+ )
+ await user.click(screen.getByRole("cell", { name: "Alpha" }))
+ expect(onRowClick).toHaveBeenCalledWith(data[0])
+ })
+
+ it("calls onRowClick when Enter is pressed on a row", async () => {
+ const onRowClick = vi.fn()
+ const user = userEvent.setup()
+ render(
+ r.id}
+ onRowClick={onRowClick}
+ />
+ )
+ const cell = screen.getByRole("cell", { name: "Alpha" })
+ const row = cell.closest("tr")!
+ row.focus()
+ await user.keyboard("{Enter}")
+ expect(onRowClick).toHaveBeenCalledWith(data[0])
+ })
+
+ it("wraps the table in an overflow-x-auto container", () => {
+ const { container } = render(
+ r.id}
+ />
+ )
+ const wrapper = container.querySelector('[data-slot="table-container"]')
+ expect(wrapper).toHaveClass("overflow-x-auto")
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+ r.id}
+ />
+ )
+ expect(await axe(container)).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx
index 61853df..70798dc 100644
--- a/packages/ui/src/components/data-table.tsx
+++ b/packages/ui/src/components/data-table.tsx
@@ -2,11 +2,23 @@ import * as React from 'react'
import { cn } from '@workspace/ui/lib/utils'
import { Skeleton } from '@workspace/ui/components/skeleton'
+import { EmptyState } from '@workspace/ui/components/empty-state'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableEmptyRow,
+ TableHead,
+ TableHeadRow,
+ TableHeader,
+ TableRow,
+} from '@workspace/ui/components/table'
interface Column {
id: string
header: string
accessor: (row: T) => React.ReactNode
+ align?: 'left' | 'right'
className?: string
}
@@ -18,6 +30,7 @@ interface DataTableProps {
emptyAction?: React.ReactNode
keyExtractor: (row: T) => string
onRowClick?: (row: T) => void
+ selectedRowKey?: string
className?: string
}
@@ -29,59 +42,64 @@ function DataTable({
emptyAction,
keyExtractor,
onRowClick,
+ selectedRowKey,
className,
}: DataTableProps) {
if (isLoading) {
return (
-
- {[0, 1, 2].map((i) => (
-
- {columns.map((col) => (
-
- ))}
-
- ))}
-
+
+
+ {[0, 1, 2].map((i) => (
+
+ {columns.map((col) => (
+
+
+
+ ))}
+
+ ))}
+
+
)
}
if (data.length === 0) {
+ const colSpan = columns.length || 1
return (
-
-
{emptyMessage}
- {emptyAction}
-
+
)
}
return (
-
-
-
-
- {columns.map((col) => (
-
- {col.header}
-
- ))}
-
-
-
- {data.map((row) => (
-
+
+
+ {columns.map((col) => (
+
+ {col.header}
+
+ ))}
+
+
+
+ {data.map((row) => {
+ const key = keyExtractor(row)
+ const isSelected = selectedRowKey !== undefined && selectedRowKey === key
+
+ return (
+ ({
: undefined
}
onClick={onRowClick ? () => onRowClick(row) : undefined}
- className={cn(
- 'border-b border-border/40 transition-colors last:border-b-0',
- onRowClick && 'cursor-pointer hover:bg-muted/20',
- )}
+ className={cn(onRowClick && 'cursor-pointer', isSelected && 'bg-muted/30')}
>
{columns.map((col) => (
-
+
{col.accessor(row)}
-
+
))}
-
- ))}
-
-
-
+
+ )
+ })}
+
+
)
}
diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx
index 820d9a1..ca15afc 100644
--- a/packages/ui/src/components/dialog.tsx
+++ b/packages/ui/src/components/dialog.tsx
@@ -54,7 +54,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
// Positioning & sizing
- "fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2",
+ "fixed top-1/2 inset-inline-start-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2",
// Mobile: cap height so content scrolls inside rather than overflowing the viewport
"max-h-[calc(100dvh-2rem)] overflow-x-hidden overflow-y-auto",
// Layout, surface, typography
diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx
index 5373ef8..da4ff01 100644
--- a/packages/ui/src/components/dropdown-menu.tsx
+++ b/packages/ui/src/components/dropdown-menu.tsx
@@ -79,14 +79,14 @@ function DropdownMenuCheckboxItem({
svg]:size-3.5 [&>svg]:shrink-0",
+ "relative flex w-full cursor-default select-none items-center gap-2 rounded-lg py-1.5 pe-2 ps-8 text-xs/relaxed outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-3.5 [&>svg]:shrink-0",
className
)}
{...props}
>
svg]:size-3.5 [&>svg]:shrink-0",
+ "relative flex w-full cursor-default select-none items-center gap-2 rounded-lg py-1.5 pe-2 ps-8 text-xs/relaxed outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-3.5 [&>svg]:shrink-0",
className
)}
{...props}
>
*
diff --git a/packages/ui/src/components/numeric.tsx b/packages/ui/src/components/numeric.tsx
index af8efd7..942b503 100644
--- a/packages/ui/src/components/numeric.tsx
+++ b/packages/ui/src/components/numeric.tsx
@@ -22,44 +22,69 @@ type LegacyNumericRole = VariantProps['role']
interface NumericProps {
value: number | null | undefined
- format?: 'usd' | 'token' | 'pct' | 'number'
+ format?: 'usd' | 'token' | 'pct' | 'number' | 'small'
role?: LegacyNumericRole
decimals?: number
compact?: boolean
+ locale?: string
+ currency?: string
fallback?: string
+ threshold?: number
className?: string
}
-function formatUsd(value: number, compact?: boolean): string {
- if (compact) {
- if (Math.abs(value) >= 1_000_000_000) {
- return `$${(value / 1_000_000_000).toFixed(1)}B`
- }
- if (Math.abs(value) >= 1_000_000) {
- return `$${(value / 1_000_000).toFixed(1)}M`
- }
- if (Math.abs(value) >= 1_000) {
- return `$${(value / 1_000).toFixed(1)}K`
- }
- }
- return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+const DEFAULT_LOCALE = "en-US"
+const SMALL_THRESHOLD = 0.0001
+
+function normalize(n: number): number {
+ return Object.is(n, -0) ? 0 : n
+}
+
+function formatUsd(value: number, compact?: boolean, locale?: string, currency?: string): string {
+ const loc = locale ?? DEFAULT_LOCALE
+ const curr = currency ?? "USD"
+ const dec = compact ? 0 : 2
+ return new Intl.NumberFormat(loc, {
+ style: "currency",
+ currency: curr,
+ minimumFractionDigits: dec,
+ maximumFractionDigits: 2,
+ ...(compact ? { notation: "compact" as const } : {}),
+ }).format(normalize(value))
}
-function formatToken(value: number, decimals?: number): string {
+function formatToken(value: number, decimals?: number, locale?: string): string {
const maxDecimals = decimals ?? 4
- return value.toLocaleString('en-US', {
+ return normalize(value).toLocaleString(locale ?? DEFAULT_LOCALE, {
minimumFractionDigits: 0,
maximumFractionDigits: maxDecimals,
})
}
function formatPct(value: number): string {
- const sign = value > 0 ? '+' : ''
- return `${sign}${value.toFixed(2)}%`
+ const v = normalize(value)
+ const sign = v > 0 ? '+' : ''
+ return `${sign}${v.toFixed(2)}%`
+}
+
+function formatNumber(value: number, locale?: string): string {
+ return normalize(value).toLocaleString(locale ?? DEFAULT_LOCALE)
}
-function formatNumber(value: number): string {
- return value.toLocaleString('en-US')
+function formatSmall(value: number, decimals?: number, threshold?: number, locale?: string): string {
+ const v = normalize(value)
+ const dec = decimals ?? 4
+ const thresh = threshold ?? SMALL_THRESHOLD
+ if (v !== 0 && Math.abs(v) < thresh) {
+ return `<${thresh.toLocaleString(locale ?? DEFAULT_LOCALE, {
+ minimumFractionDigits: dec,
+ maximumFractionDigits: dec,
+ })}`
+ }
+ return v.toLocaleString(locale ?? DEFAULT_LOCALE, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: dec,
+ })
}
/**
@@ -131,7 +156,10 @@ function Numeric({
role = 'neutral',
decimals,
compact = false,
+ locale,
+ currency,
fallback = '\u2014',
+ threshold,
className,
}: NumericProps) {
const display = (() => {
@@ -140,13 +168,15 @@ function Numeric({
}
switch (format) {
case 'usd':
- return formatUsd(value, compact)
+ return formatUsd(value, compact, locale, currency)
case 'token':
- return formatToken(value, decimals)
+ return formatToken(value, decimals, locale)
case 'pct':
return formatPct(value)
+ case 'small':
+ return formatSmall(value, decimals, threshold, locale)
case 'number':
- return formatNumber(value)
+ return formatNumber(value, locale)
}
})()
@@ -161,4 +191,4 @@ function Numeric({
}
export { Numeric, numericVariants, NumericText, numericTextVariants, numericRoleForValue }
-export type { NumericRole, NumericProps, LegacyNumericRole, NumericTextProps }
+export type { NumericRole, NumericProps, LegacyNumericRole, NumericTextProps }
\ No newline at end of file
diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx
index 0e2e3b1..69b0b19 100644
--- a/packages/ui/src/components/popover.tsx
+++ b/packages/ui/src/components/popover.tsx
@@ -42,7 +42,7 @@ function PopoverContent({
svg]:size-3.5 [&>svg]:shrink-0",
+ "relative flex w-full cursor-default items-center gap-2 rounded-lg py-1.5 pe-2 ps-8 text-xs/relaxed outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-3.5 [&>svg]:shrink-0",
className
)}
{...props}
>
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx
index 62f1599..5f990e3 100644
--- a/packages/ui/src/components/switch.tsx
+++ b/packages/ui/src/components/switch.tsx
@@ -17,7 +17,7 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
>
)
diff --git a/packages/ui/src/components/table-toolbar.tsx b/packages/ui/src/components/table-toolbar.tsx
index 60b8a1f..e07c626 100644
--- a/packages/ui/src/components/table-toolbar.tsx
+++ b/packages/ui/src/components/table-toolbar.tsx
@@ -44,7 +44,7 @@ function TableToolbar({
)}
{count != null && (
-
+
{count} {count === 1 ? 'result' : 'results'}
)}
diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx
index 6597393..d7cfbee 100644
--- a/packages/ui/src/components/table.tsx
+++ b/packages/ui/src/components/table.tsx
@@ -38,7 +38,7 @@ function TableHeadRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
)
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index e9fd1b1..38c0874 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -60,10 +60,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+
@@ -103,7 +103,7 @@ function TooltipShortcut({