diff --git a/apps/web/src/features/earn/components/earn-page.tsx b/apps/web/src/features/earn/components/earn-page.tsx
index b4e5d08..384314c 100644
--- a/apps/web/src/features/earn/components/earn-page.tsx
+++ b/apps/web/src/features/earn/components/earn-page.tsx
@@ -1,5 +1,6 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"
-import { Heading, Text } from "@workspace/ui/components/text"
+import { AppShell } from "@workspace/ui/components/app-shell"
+import { PageHeader } from "@workspace/ui/components/page-header"
import { Navbar } from "../../../ui/Navbar"
import { PortfolioTab } from "./portfolio/portfolio-tab"
import { DiscoverTab } from "./discover/discover-tab"
@@ -8,38 +9,34 @@ import { DistributionsTab } from "./distributions/distributions-tab"
export function EarnPage() {
return (
-
-
-
-
+
} maxWidth="260">
+
+
+ Portfolio
+ Discover
+ Additional opportunities
+ Distributions
+
-
-
- Portfolio
- Discover
- Additional opportunities
- Distributions
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
)
}
diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx
index 1acb488..50ca46b 100644
--- a/apps/web/src/features/faucet/components/faucet-page.tsx
+++ b/apps/web/src/features/faucet/components/faucet-page.tsx
@@ -1,10 +1,9 @@
import { Alert, AlertDescription } from "@workspace/ui/components/alert"
-import { Badge } from "@workspace/ui/components/badge"
+import { AppShell } from "@workspace/ui/components/app-shell"
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 { PageHeader } from "@workspace/ui/components/page-header"
+import { 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"
@@ -121,24 +120,25 @@ export function FaucetPage() {
const claimDisabled = !isConnected || mismatch
return (
-
-
-
-
-
- {/* Header */}
-
+ }
+ banner={ }
+ maxWidth="2xl"
+ >
+
- Testnet Faucet
-
-
- Stellar Testnet
-
+ Testnet Faucet
-
- Claim test tokens to try trading on SO4. Tokens have no real value.
-
-
+ }
+ description="Claim test tokens to try trading on SO4. Tokens have no real value."
+ metadata={
+
+
+ Stellar Testnet
+
+ }
+ />
{!isTestnet ? (
@@ -176,7 +176,7 @@ export function FaucetPage() {
Receive TUSDC, TWBTC, TETH, and TXLM in a single transaction. A cooldown
applies between claims.
-
+
{mismatch && (
@@ -209,7 +209,7 @@ export function FaucetPage() {
{data?.cooldownLedgers != null && data.cooldownLedgers > 0 && (
Cooldown: {data.cooldownLedgers.toLocaleString()} ledgers between claims
-
+
)}
@@ -238,7 +238,6 @@ export function FaucetPage() {
)}
-
-
+
)
}
diff --git a/apps/web/src/features/gallery/components/gallery-page.tsx b/apps/web/src/features/gallery/components/gallery-page.tsx
index ab6f8bd..a1f7eba 100644
--- a/apps/web/src/features/gallery/components/gallery-page.tsx
+++ b/apps/web/src/features/gallery/components/gallery-page.tsx
@@ -1,7 +1,9 @@
import { useState } from "react"
+import { AppShell } from "@workspace/ui/components/app-shell"
import { Badge } from "@workspace/ui/components/badge"
import { Button } from "@workspace/ui/components/button"
import { Input } from "@workspace/ui/components/input"
+import { PageHeader } from "@workspace/ui/components/page-header"
import { Separator } from "@workspace/ui/components/separator"
import { Skeleton } from "@workspace/ui/components/skeleton"
import { Slider } from "@workspace/ui/components/slider"
@@ -139,6 +141,123 @@ export function GalleryPage() {
A tooltip, positioned automatically.
+
+
+
+
+ Constrained layout (default) — used for Pools, Earn, Referrals, Faucet
+
+
+ }
+ banner={
+
+ Banner slot
+
+ }
+ >
+
+ Content area
+
+
+
+
+
+ Full layout — used for Trade (fills viewport)
+
+
+ }
+ >
+
+ Full-viewport content area
+
+
+
+
+
+
+
+
+
+
Minimal — title + description
+
+
+
+
+
With actions slot
+
+
+
+ Filter
+
+ Claim all
+
+ }
+ />
+
+
+
+
+
With metadata + tabs slot
+
+
+ Active
+ Tier 3
+ >
+ }
+ tabs={
+
+
+ Traders
+ Affiliates
+ Distributions
+
+
+ }
+ />
+
+
+
+
+
With breadcrumbs slot
+
+
+ Home
+ /
+ Settings
+
+ }
+ />
+
+
+
+
)
}
diff --git a/apps/web/src/features/pools/components/pools-page.tsx b/apps/web/src/features/pools/components/pools-page.tsx
index 46d58a0..05bc623 100644
--- a/apps/web/src/features/pools/components/pools-page.tsx
+++ b/apps/web/src/features/pools/components/pools-page.tsx
@@ -1,4 +1,6 @@
import { Link } from "@tanstack/react-router"
+import { AppShell } from "@workspace/ui/components/app-shell"
+import { PageHeader } from "@workspace/ui/components/page-header"
import { usePoolsData } from "../hooks/use-pools-data"
import { usePoolsTimeRange } from "../hooks/use-pools-time-range"
import { PoolsCard } from "./pools-card"
@@ -13,18 +15,15 @@ export function PoolsPage() {
const { markets, glvEnabled } = usePoolsData(timeRange)
return (
-
-
+ }
+ />
Deposit and withdrawal transactions are disabled until liquidity seeding and oracle
@@ -65,7 +65,6 @@ export function PoolsPage() {
GLV vaults are hidden because GLV contracts are not deployed for this testnet.
) : null}
-
-
+
)
}
diff --git a/apps/web/src/features/referrals/components/referrals-page.tsx b/apps/web/src/features/referrals/components/referrals-page.tsx
index 3b5db4f..d65a7ba 100644
--- a/apps/web/src/features/referrals/components/referrals-page.tsx
+++ b/apps/web/src/features/referrals/components/referrals-page.tsx
@@ -1,6 +1,7 @@
import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"
-import { Heading, Text } from "@workspace/ui/components/text"
+import { AppShell } from "@workspace/ui/components/app-shell"
+import { PageHeader } from "@workspace/ui/components/page-header"
import { useQueryClient } from "@tanstack/react-query"
import { Navbar } from "../../../ui/Navbar"
import { useTraderStats } from "../hooks/use-referrals-data"
@@ -46,21 +47,16 @@ export function ReferralsPage() {
const hasAffiliateCode = Boolean(affiliateCode)
return (
-
-
-
-
-
-
setTab(v as ReferralsTab)}
- className="gap-6"
- >
+ } maxWidth="260">
+ setTab(v as ReferralsTab)}
+ className="gap-6"
+ >
Traders
Affiliates
@@ -102,7 +98,7 @@ export function ReferralsPage() {
/>
-
-
+
+
)
}
diff --git a/apps/web/src/features/trade/components/TradePage.tsx b/apps/web/src/features/trade/components/TradePage.tsx
index eba651e..d368599 100644
--- a/apps/web/src/features/trade/components/TradePage.tsx
+++ b/apps/web/src/features/trade/components/TradePage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"
import { getRouteApi } from "@tanstack/react-router"
import { useTradeState } from "../hooks/useTradeState"
import { useOrderEventPolling } from "../hooks/useOrderEventPolling"
+import { AppShell } from "@workspace/ui/components/app-shell"
import { Navbar } from "../../../ui/Navbar"
import { TVChart } from "./chart/TVChart"
import { TradePanel } from "./trade-panel/TradePanel"
@@ -36,9 +37,12 @@ export function TradePage() {
}, [search.ref])
return (
-
-
-
+
}
+ banner={
}
+ className="overflow-hidden"
+ >
{/* ── Left: Chart + Bottom Tabs ──────────────────────────────── */}
@@ -67,6 +71,6 @@ export function TradePage() {
-
+
)
}
diff --git a/apps/web/src/ui/Navbar.tsx b/apps/web/src/ui/Navbar.tsx
index 043785f..f91be4c 100644
--- a/apps/web/src/ui/Navbar.tsx
+++ b/apps/web/src/ui/Navbar.tsx
@@ -1,50 +1,18 @@
-import { useState } from "react"
import { Link } from "@tanstack/react-router"
import { Button } from "@workspace/ui/components/button"
import { ThemeToggle } from "./theme-toggle"
import { ConnectButton } from "@/features/wallet/components/ConnectButton"
-
-function Logo() {
- return (
-
-
-
-
-
-
-
-
-
-
- so4.market
-
-
- )
-}
+import {
+ SiteLogo,
+ HamburgerButton,
+ useMobileMenu,
+ navOuterClass,
+ containerClass,
+ desktopLinkClass,
+ desktopActiveLinkClass,
+ mobileLinkClass,
+ mobileActiveLinkClass,
+} from "./nav/primitives"
const LANDING_LINKS = [
{ label: "Trade", href: "/trade" },
@@ -64,35 +32,23 @@ const APP_LINKS: Array<{ label: string; to: "/trade" | "/pools" | "/earn" | "/re
{ label: "Docs", to: null },
]
-const desktopAppLinkClass =
- "relative inline-flex h-8 items-center rounded-md px-2 text-13-5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
-
-const desktopAppLinkActiveClass =
- "relative inline-flex h-8 items-center rounded-md bg-primary/10 px-2 text-13-5 font-medium text-foreground after:absolute after:inset-x-2 after:-bottom-[13px] after:h-0.5 after:rounded-full after:bg-primary"
-
-const mobileAppLinkClass =
- "block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
-
-const mobileAppLinkActiveClass =
- "block rounded-md bg-primary/10 px-3 py-2 text-sm font-medium text-foreground ring-1 ring-primary/20"
-
type Props = {
variant: "landing" | "app"
}
export function Navbar({ variant }: Props) {
- const [mobileOpen, setMobileOpen] = useState(false)
+ const { open, toggle, close } = useMobileMenu()
const isApp = variant === "app"
return (
-
+
-
+
{/* Desktop links */}
@@ -103,9 +59,9 @@ export function Navbar({ variant }: Props) {
{to ? (
{label}
@@ -118,10 +74,7 @@ export function Navbar({ variant }: Props) {
))
: LANDING_LINKS.map(({ label, href }) => (
-
+
{label}
@@ -141,46 +94,12 @@ export function Navbar({ variant }: Props) {
→
)}
-
- {/* Mobile hamburger */}
-
setMobileOpen((v) => !v)}
- >
- {mobileOpen ? (
-
-
-
- ) : (
-
-
-
- )}
-
+
{/* Mobile dropdown */}
- {mobileOpen && (
+ {open && (
{isApp
@@ -189,10 +108,10 @@ export function Navbar({ variant }: Props) {
{to ? (
setMobileOpen(false)}
+ activeProps={{ className: mobileActiveLinkClass }}
+ onClick={close}
>
{label}
@@ -208,7 +127,7 @@ export function Navbar({ variant }: Props) {
setMobileOpen(false)}
+ onClick={close}
>
{label}
diff --git a/apps/web/src/ui/landing/nav.tsx b/apps/web/src/ui/landing/nav.tsx
index 81b3421..4c018a2 100644
--- a/apps/web/src/ui/landing/nav.tsx
+++ b/apps/web/src/ui/landing/nav.tsx
@@ -1,74 +1,44 @@
-import { useState } from "react"
+import { useLocation } from "@tanstack/react-router"
import { Button } from "@workspace/ui/components/button"
+import { ConnectButton } from "@/features/wallet/components/ConnectButton"
import { ThemeToggle } from "../theme-toggle"
-
-function Logo() {
- return (
-
-
-
-
-
-
-
-
-
-
- so4.market
-
-
- )
-}
+import {
+ SiteLogo,
+ HamburgerButton,
+ useMobileMenu,
+ navOuterClass,
+ containerClass,
+ desktopLinkClass,
+ desktopActiveLinkClass,
+ mobileLinkClass,
+ mobileActiveLinkClass,
+} from "../nav/primitives"
const NAV_LINKS = [
- { label: "Trade", href: "#", active: true },
- { label: "Earn", href: "#" },
+ { label: "Trade", href: "/trade" },
+ { label: "Earn", href: "/earn" },
{ label: "Stats", href: "#" },
{ label: "Docs", href: "#" },
{ label: "Governance", href: "#" },
]
export function Nav() {
- const [mobileOpen, setMobileOpen] = useState(false)
+ const { open, toggle, close } = useMobileMenu()
+ const { pathname } = useLocation()
+ const isActive = (href: string) => href !== "#" && pathname === href
return (
-
-
-
+
+
+
{/* Desktop nav links */}
- {NAV_LINKS.map(({ label, href, active }) => (
+ {NAV_LINKS.map(({ label, href }) => (
{label}
@@ -79,66 +49,21 @@ export function Nav() {
{/* Actions */}
-
- Connect
-
-
- Launch app
- →
-
-
- {/* Mobile hamburger */}
-
setMobileOpen((v) => !v)}
- >
- {mobileOpen ? (
-
-
-
- ) : (
-
-
-
- )}
-
+
+
{/* Mobile dropdown */}
- {mobileOpen && (
+ {open && (
- {NAV_LINKS.map(({ label, href, active }) => (
+ {NAV_LINKS.map(({ label, href }) => (
setMobileOpen(false)}
+ className={isActive(href) ? mobileActiveLinkClass : mobileLinkClass}
+ onClick={close}
>
{label}
diff --git a/apps/web/src/ui/nav/primitives.tsx b/apps/web/src/ui/nav/primitives.tsx
new file mode 100644
index 0000000..b765148
--- /dev/null
+++ b/apps/web/src/ui/nav/primitives.tsx
@@ -0,0 +1,137 @@
+import { useState, useEffect, useCallback } from "react"
+import { Link } from "@tanstack/react-router"
+import { Button } from "@workspace/ui/components/button"
+
+// ── Shared classes ──
+
+export const navOuterClass =
+ "sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md backdrop-saturate-150"
+
+export const containerClass =
+ "mx-auto flex min-w-0 items-center justify-between gap-2 px-3 sm:px-6 lg:px-8"
+
+export const desktopLinkClass =
+ "relative inline-flex h-8 items-center rounded-md px-2 text-13-5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
+
+export const desktopActiveLinkClass =
+ "relative inline-flex h-8 items-center rounded-md bg-primary/10 px-2 text-13-5 font-medium text-foreground after:absolute after:inset-x-2 after:-bottom-[13px] after:h-0.5 after:rounded-full after:bg-primary"
+
+export const mobileLinkClass =
+ "block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
+
+export const mobileActiveLinkClass =
+ "block rounded-md bg-primary/10 px-3 py-2 text-sm font-medium text-foreground ring-1 ring-primary/20"
+
+// ── Logo ──
+
+interface SiteLogoProps {
+ variant?: "app" | "landing"
+}
+
+export function SiteLogo({ variant = "app" }: SiteLogoProps) {
+ const content = (
+ <>
+
+
+
+
+
+
+
+
+
+ so4.market
+
+ >
+ )
+
+ if (variant === "app") {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+// ── Hamburger ──
+
+interface HamburgerButtonProps {
+ open: boolean
+ onToggle: () => void
+}
+
+export function HamburgerButton({ open, onToggle }: HamburgerButtonProps) {
+ return (
+
+ {open ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+// ── Mobile menu hook ──
+
+export function useMobileMenu() {
+ const [open, setOpen] = useState(false)
+
+ const toggle = useCallback(() => setOpen((v) => !v), [])
+ const close = useCallback(() => setOpen(false), [])
+
+ useEffect(() => {
+ if (!open) return
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setOpen(false)
+ }
+ }
+ document.addEventListener("keydown", handler)
+ return () => document.removeEventListener("keydown", handler)
+ }, [open])
+
+ return { open, toggle, close }
+}
diff --git a/packages/ui/src/components/app-shell.test.tsx b/packages/ui/src/components/app-shell.test.tsx
new file mode 100644
index 0000000..a02f2f6
--- /dev/null
+++ b/packages/ui/src/components/app-shell.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { axe } from "vitest-axe"
+import { AppShell } from "./app-shell"
+
+describe("AppShell", () => {
+ it("renders children within the content area", () => {
+ render(
+ }>
+ Page content
+
+ )
+ expect(screen.getByTestId("navbar")).toBeInTheDocument()
+ expect(screen.getByTestId("content")).toBeInTheDocument()
+ })
+
+ it("renders banner when provided", () => {
+ render(
+ } banner={
}>
+ Content
+
+ )
+ expect(screen.getByTestId("banner")).toBeInTheDocument()
+ })
+
+ it("applies full variant class", () => {
+ const { container } = render(
+ }>
+ Content
+
+ )
+ const shell = container.querySelector("[data-slot='app-shell']")
+ expect(shell).toHaveAttribute("data-variant", "full")
+ })
+
+ it("applies constrained variant class by default", () => {
+ const { container } = render(
+ }>
+ Content
+
+ )
+ const shell = container.querySelector("[data-slot='app-shell']")
+ expect(shell).toHaveAttribute("data-variant", "constrained")
+ })
+
+ it("accepts a custom maxWidth", () => {
+ const { container } = render(
+ }>
+ Content
+
+ )
+ const content = container.querySelector("[data-slot='app-shell-content']")
+ expect(content).toBeTruthy()
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+ }>
+ Content
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
\ No newline at end of file
diff --git a/packages/ui/src/components/app-shell.tsx b/packages/ui/src/components/app-shell.tsx
new file mode 100644
index 0000000..008f120
--- /dev/null
+++ b/packages/ui/src/components/app-shell.tsx
@@ -0,0 +1,97 @@
+import * as React from "react"
+import { cva } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+import type { VariantProps } from "class-variance-authority"
+
+const shellVariants = cva(
+ "flex min-h-svh flex-col bg-background text-foreground",
+ {
+ variants: {
+ variant: {
+ /** Full-viewport layout for the trading page — no max-width, fills space. */
+ full: "",
+ /** Constrained content layout with a max-width container. */
+ constrained: "",
+ },
+ },
+ defaultVariants: {
+ variant: "constrained",
+ },
+ }
+)
+
+const contentVariants = cva("w-full", {
+ variants: {
+ variant: {
+ full: "flex min-h-0 flex-1 flex-col overflow-hidden",
+ constrained: "mx-auto px-4 pb-16 pt-8 sm:px-6",
+ },
+ },
+ defaultVariants: {
+ variant: "constrained",
+ },
+})
+
+type MaxWidthKey = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "260" | "320" | "330" | "full"
+
+const MAX_WIDTH_CLASS: Record = {
+ sm: "max-w-sm",
+ md: "max-w-md",
+ lg: "max-w-lg",
+ xl: "max-w-xl",
+ "2xl": "max-w-2xl",
+ "3xl": "max-w-3xl",
+ "260": "max-w-260",
+ "320": "max-w-320",
+ "330": "max-w-330",
+ full: "max-w-full",
+}
+
+export interface AppShellProps
+ extends React.ComponentProps<"div">,
+ VariantProps {
+ /** Top navigation bar. Rendered at the top of the shell, outside the scrollable content area. */
+ navbar: React.ReactNode
+ /** Application-level banner(s) — NetworkMismatchBanner, CircuitBreakerBanner, etc. */
+ banner?: React.ReactNode
+ /**
+ * Max-width for the constrained variant. Use one of the token-defined widths.
+ * @default "320"
+ */
+ maxWidth?: MaxWidthKey
+}
+
+function AppShell({
+ variant = "constrained",
+ maxWidth = "320",
+ navbar,
+ banner,
+ className,
+ children,
+ ...props
+}: AppShellProps) {
+ return (
+
+ {navbar}
+ {banner}
+
+ {children}
+
+
+ )
+}
+
+export { AppShell, shellVariants }
\ No newline at end of file
diff --git a/packages/ui/src/components/breadcrumb.test.tsx b/packages/ui/src/components/breadcrumb.test.tsx
new file mode 100644
index 0000000..8276157
--- /dev/null
+++ b/packages/ui/src/components/breadcrumb.test.tsx
@@ -0,0 +1,110 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { axe } from "vitest-axe"
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+} from "./breadcrumb"
+
+describe("Breadcrumb", () => {
+ it("renders a nav with an ordered list", () => {
+ render(
+
+
+ Pools
+
+ ,
+ )
+ expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument()
+ expect(screen.getByRole("list")).toBeInTheDocument()
+ })
+
+ it("marks the current page with aria-current", () => {
+ render(
+
+
+ Pools
+
+
+ GM Markets
+
+ ,
+ )
+ const current = screen.getByText("GM Markets")
+ expect(current.closest("li")).toHaveAttribute("aria-current", "page")
+ })
+
+ it("renders separators between items", () => {
+ const { container } = render(
+
+
+ Home
+
+
+
+ Settings
+
+ ,
+ )
+ const separators = container.querySelectorAll("[data-slot='breadcrumb-separator']")
+ expect(separators).toHaveLength(1)
+ })
+
+ it("collapses middle items when maxItems is exceeded", () => {
+ render(
+
+
+ Home
+
+
+
+ Pools
+
+
+
+ GM Markets
+
+ ,
+ )
+ expect(screen.getByText("Home")).toBeInTheDocument()
+ expect(screen.getByText("GM Markets")).toBeInTheDocument()
+ expect(screen.queryByText("Pools")).not.toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "More pages" })).toBeInTheDocument()
+ })
+
+ it("does not collapse when within maxItems", () => {
+ render(
+
+
+ Home
+
+
+
+ Page
+
+ ,
+ )
+ expect(screen.getByText("Home")).toBeInTheDocument()
+ expect(screen.getByText("Page")).toBeInTheDocument()
+ expect(screen.queryByRole("button", { name: "More pages" })).not.toBeInTheDocument()
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+
+ Pools
+
+
+
+ GM Markets
+
+ ,
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/breadcrumb.tsx b/packages/ui/src/components/breadcrumb.tsx
new file mode 100644
index 0000000..d330275
--- /dev/null
+++ b/packages/ui/src/components/breadcrumb.tsx
@@ -0,0 +1,118 @@
+import * as React from "react"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+// ── Breadcrumb ──
+
+export interface BreadcrumbProps extends React.ComponentProps<"nav"> {
+ /** Collapse to first + last when children exceed this count. */
+ maxItems?: number
+}
+
+function Breadcrumb({ maxItems, label = "Breadcrumb", className, children, ...props }: BreadcrumbProps & { label?: string }) {
+ const items = React.Children.toArray(children)
+ const shouldCollapse = maxItems != null && items.length > maxItems
+
+ return (
+
+
+ {shouldCollapse ? (
+ <>
+ {items.slice(0, 1)}
+
+
+
+
+ {items.slice(-1)}
+ >
+ ) : (
+ children
+ )}
+
+
+ )
+}
+
+// ── BreadcrumbItem ──
+
+export interface BreadcrumbItemProps extends React.ComponentProps<"li"> {
+ /** Marks this item as the current page. */
+ isCurrent?: boolean
+}
+
+function BreadcrumbItem({ isCurrent, className, children, ...props }: BreadcrumbItemProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ── BreadcrumbLink ──
+
+export interface BreadcrumbLinkProps extends React.ComponentProps<"a"> {
+ /** Render as or . Current-page links should use "span". */
+ as?: "a" | "span"
+}
+
+function BreadcrumbLink({ as: Tag = "a", className, ...props }: BreadcrumbLinkProps) {
+ return (
+
+ )
+}
+
+// ── BreadcrumbSeparator ──
+
+function BreadcrumbSeparator({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ /
+
+ )
+}
+
+// ── BreadcrumbEllipsis ──
+
+export interface BreadcrumbEllipsisProps extends React.ComponentProps<"span"> {
+ /** Accessible label for the ellipsis menu trigger. */
+ label?: string
+}
+
+function BreadcrumbEllipsis({ label = "More pages", className, ...props }: BreadcrumbEllipsisProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbEllipsis }
diff --git a/packages/ui/src/components/page-header.test.tsx b/packages/ui/src/components/page-header.test.tsx
new file mode 100644
index 0000000..b8da5a6
--- /dev/null
+++ b/packages/ui/src/components/page-header.test.tsx
@@ -0,0 +1,76 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { axe } from "vitest-axe"
+import { PageHeader } from "./page-header"
+
+describe("PageHeader", () => {
+ it("renders title and description", () => {
+ render( )
+ expect(screen.getByText("Pools")).toBeInTheDocument()
+ expect(screen.getByText("Manage your liquidity")).toBeInTheDocument()
+ })
+
+ it("renders actions slot", () => {
+ render(
+ Filter}
+ />
+ )
+ expect(screen.getByTestId("action")).toBeInTheDocument()
+ })
+
+ it("renders metadata slot", () => {
+ render(
+ TVL: $0 }
+ />
+ )
+ expect(screen.getByTestId("meta")).toBeInTheDocument()
+ })
+
+ it("renders tabs slot", () => {
+ render(
+ Tab items }
+ />
+ )
+ expect(screen.getByTestId("tabs")).toBeInTheDocument()
+ })
+
+ it("renders breadcrumbs slot", () => {
+ render(
+ Home / Page}
+ />
+ )
+ expect(screen.getByTestId("breadcrumbs")).toBeInTheDocument()
+ })
+
+ it("renders children", () => {
+ render(
+
+ Extra content
+
+ )
+ expect(screen.getByTestId("child")).toBeInTheDocument()
+ })
+
+ it("renders title as ReactNode", () => {
+ render(
+ Custom Title} />
+ )
+ expect(screen.getByTestId("custom-title")).toBeInTheDocument()
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
\ No newline at end of file
diff --git a/packages/ui/src/components/page-header.tsx b/packages/ui/src/components/page-header.tsx
new file mode 100644
index 0000000..8d48a08
--- /dev/null
+++ b/packages/ui/src/components/page-header.tsx
@@ -0,0 +1,96 @@
+import * as React from "react"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+export interface PageHeaderProps extends React.ComponentProps<"header"> {
+ /** Page title — the primary heading. */
+ title: React.ReactNode
+ /** Subtitle / description text rendered below the title. */
+ description?: React.ReactNode
+ /**
+ * Action slot rendered on the right (desktop) or below the title (mobile).
+ * Typically buttons, filters, or time-range controls.
+ */
+ actions?: React.ReactNode
+ /** Metadata slot rendered below the description — stats, badges, etc. */
+ metadata?: React.ReactNode
+ /**
+ * Sub-navigation tabs rendered below the header content.
+ * Pass the entire `` + `` elements.
+ */
+ tabs?: React.ReactNode
+ /** Breadcrumb navigation rendered above the title. */
+ breadcrumbs?: React.ReactNode
+}
+
+function PageHeader({
+ title,
+ description,
+ actions,
+ metadata,
+ tabs,
+ breadcrumbs,
+ className,
+ children,
+ ...props
+}: PageHeaderProps) {
+ return (
+
+ )
+}
+
+export { PageHeader }
\ No newline at end of file