diff --git a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx
index 28374e0..34a0b08 100644
--- a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx
+++ b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx
@@ -1,4 +1,5 @@
import { cn } from "@workspace/ui/lib/utils"
+import type { NumericRole } from "@workspace/ui/components/numeric"
import type { TimePeriod } from "../../hooks/use-referrals-data"
import { formatUsd } from "@/shared/lib/format"
diff --git a/packages/ui/src/components/button.test.tsx b/packages/ui/src/components/button.test.tsx
index b205787..60ff4e5 100644
--- a/packages/ui/src/components/button.test.tsx
+++ b/packages/ui/src/components/button.test.tsx
@@ -1,7 +1,27 @@
import { describe, it, expect } from "vitest"
-import { render } from "@testing-library/react"
+import { render, screen } from "@testing-library/react"
import { axe } from "vitest-axe"
-import { Button } from "./button"
+import { Button, buttonVariants } from "./button"
+
+const VARIANTS = [
+ "default",
+ "outline",
+ "secondary",
+ "ghost",
+ "destructive",
+ "link",
+] as const
+
+const SIZES = [
+ "default",
+ "xs",
+ "sm",
+ "lg",
+ "icon",
+ "icon-xs",
+ "icon-sm",
+ "icon-lg",
+] as const
describe("Button accessibility", () => {
it("has no accessibility violations", async () => {
@@ -26,3 +46,44 @@ describe("Button accessibility", () => {
expect(results).toHaveNoViolations()
})
})
+
+describe("Button variants and sizes", () => {
+ it.each(VARIANTS)("renders the %s variant without violations", async (variant) => {
+ const { container } = render()
+ expect(await axe(container)).toHaveNoViolations()
+ })
+
+ it.each(SIZES)("renders the %s size without violations", async (size) => {
+ const isIconSize = size.startsWith("icon")
+ const { container } = render(
+ isIconSize ? (
+
+ ) : (
+
+ )
+ )
+ expect(await axe(container)).toHaveNoViolations()
+ })
+
+ it("every variant defines default, hover, active, and disabled classes", () => {
+ for (const variant of VARIANTS) {
+ const className = buttonVariants({ variant })
+ expect(className).toMatch(/hover:/)
+ expect(className).toMatch(/active:/)
+ }
+ // disabled + focus-visible states live in the shared base classes.
+ expect(buttonVariants({})).toMatch(/disabled:/)
+ expect(buttonVariants({})).toMatch(/focus-visible:/)
+ })
+
+ it("keeps the icon-only button's accessible name from aria-label, not the icon", () => {
+ render(
+
+ )
+ expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument()
+ })
+})
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 1d8948c..e83a3ae 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -9,16 +9,17 @@ const buttonVariants = cva(
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ default:
+ "bg-primary text-primary-foreground hover:bg-primary/80 active:not-aria-[haspopup]:bg-primary/70",
outline:
- "border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
+ "border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground active:not-aria-[haspopup]:bg-input/70 dark:bg-input/30 dark:active:not-aria-[haspopup]:bg-input/50",
secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground active:not-aria-[haspopup]:bg-secondary/70",
ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground active:not-aria-[haspopup]:bg-muted/70 dark:hover:bg-muted/50 dark:active:not-aria-[haspopup]:bg-muted/40",
destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 active:not-aria-[haspopup]:bg-destructive/30 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40 dark:active:not-aria-[haspopup]:bg-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline active:not-aria-[haspopup]:text-primary/70",
},
size: {
default:
diff --git a/packages/ui/src/components/loading-button.test.tsx b/packages/ui/src/components/loading-button.test.tsx
new file mode 100644
index 0000000..6da1f84
--- /dev/null
+++ b/packages/ui/src/components/loading-button.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { axe } from "vitest-axe"
+import { LoadingButton } from "./loading-button"
+
+describe("LoadingButton", () => {
+ it("is disabled and aria-busy while loading, with text content", async () => {
+ const { container } = render(
+
+ Stake
+
+ )
+ const button = screen.getByRole("button", { name: "Confirming..." })
+ expect(button).toBeDisabled()
+ expect(button).toHaveAttribute("aria-busy", "true")
+ expect(await axe(container)).toHaveNoViolations()
+ })
+
+ it("falls back to children as the loading label when loadingText is omitted", () => {
+ render(Stake)
+ expect(screen.getByRole("button", { name: "Stake" })).toBeInTheDocument()
+ })
+
+ it("is not disabled and not aria-busy when not loading", () => {
+ render(Stake)
+ const button = screen.getByRole("button", { name: "Stake" })
+ expect(button).not.toBeDisabled()
+ expect(button).not.toHaveAttribute("aria-busy")
+ })
+
+ it("keeps the width-reserving spinner slot present (but hidden) when not loading", () => {
+ const { container } = render(Stake)
+ const slot = container.querySelector('[aria-hidden="true"]')
+ expect(slot).toBeInTheDocument()
+ expect(slot).toHaveClass("invisible")
+ })
+
+ it("supports an icon-only loading button, keeping its accessible name", async () => {
+ const { container } = render(
+
+
+
+ )
+ const button = screen.getByRole("button", { name: "Stake" })
+ expect(button).toBeDisabled()
+ expect(button).toHaveAttribute("aria-busy", "true")
+ expect(await axe(container)).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/loading-button.tsx b/packages/ui/src/components/loading-button.tsx
index 633e2a7..0755fce 100644
--- a/packages/ui/src/components/loading-button.tsx
+++ b/packages/ui/src/components/loading-button.tsx
@@ -13,6 +13,10 @@ type LoadingButtonProps = ComponentProps & {
* 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).
+ *
+ * The spinner sits in a fixed-size slot that's always present (just hidden
+ * via `invisible` when not loading), so entering the loading state never
+ * changes the button's width.
*/
function LoadingButton({
isLoading = false,
@@ -30,14 +34,16 @@ function LoadingButton({
className={cn("gap-1.5", className)}
{...props}
>
- {isLoading ? (
- <>
-
- {loadingText ?? children}
- >
- ) : (
- children
- )}
+
+ {isLoading && }
+
+ {isLoading ? (loadingText ?? children) : children}
)
}
diff --git a/packages/ui/src/components/numeric.tsx b/packages/ui/src/components/numeric.tsx
index 5510237..f457adb 100644
--- a/packages/ui/src/components/numeric.tsx
+++ b/packages/ui/src/components/numeric.tsx
@@ -19,12 +19,12 @@ const numericVariants = cva('font-mono tabular-nums', {
},
})
-type NumericRole = VariantProps['role']
+type LegacyNumericRole = VariantProps['role']
interface NumericProps {
value: number | null | undefined
format?: 'usd' | 'token' | 'pct' | 'number'
- role?: NumericRole
+ role?: LegacyNumericRole
decimals?: number
compact?: boolean
fallback?: string
@@ -63,6 +63,69 @@ function formatNumber(value: number): string {
return value.toLocaleString('en-US')
}
+/**
+ * Semantic tone for a numeric value already formatted by the caller.
+ *
+ * Unlike `Numeric` below, this primitive does not format or round its
+ * children — it only supplies the tabular-figure font treatment and the
+ * approved positive/negative/warning/accent/muted tones (DS-012).
+ */
+const numericTextVariants = cva("font-mono tabular-nums slashed-zero", {
+ variants: {
+ role: {
+ neutral: "text-foreground",
+ positive: "text-success",
+ negative: "text-destructive",
+ warning: "text-warning",
+ accent: "text-primary",
+ muted: "text-muted-foreground",
+ },
+ 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",
+ },
+ weight: {
+ normal: "font-normal",
+ medium: "font-medium",
+ semibold: "font-semibold",
+ bold: "font-bold",
+ },
+ },
+ defaultVariants: {
+ role: "neutral",
+ size: "base",
+ weight: "normal",
+ },
+})
+
+type NumericRole = VariantProps['role']
+
+type NumericTextProps = React.ComponentProps<'span'> &
+ VariantProps
+
+function NumericText({ role, size, weight, className, ...props }: NumericTextProps) {
+ return (
+
+ )
+}
+
+/** Derives a positive/negative/neutral role from a signed value's sign. */
+function numericRoleForValue(
+ value: number
+): Extract {
+ if (value > 0) return 'positive'
+ if (value < 0) return 'negative'
+ return 'neutral'
+}
+
function Numeric({
value,
format = 'number',
@@ -98,5 +161,5 @@ function Numeric({
)
}
-export { Numeric, numericVariants }
-export type { NumericRole, NumericProps }
+export { Numeric, numericVariants, NumericText, numericTextVariants, numericRoleForValue }
+export type { NumericRole, NumericProps, LegacyNumericRole, NumericTextProps }
diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx
index 9c88ce3..a2724d4 100644
--- a/packages/ui/src/components/spinner.tsx
+++ b/packages/ui/src/components/spinner.tsx
@@ -17,7 +17,7 @@ function Spinner({ className, label, ...props }: SpinnerProps) {
? { role: "status", "aria-label": label }
: { "aria-hidden": true })}
className={cn(
- "inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent",
+ "inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none motion-reduce:opacity-60",
className
)}
{...props}