diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 88adca85..701cfecd 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -2,15 +2,15 @@ import { createFileRoute } from "@tanstack/react-router"
import "../styles/landing.css"
-import { Navbar } from "../ui/Navbar"
-import { Hero } from "../ui/landing/hero"
-import { Stats } from "../ui/landing/stats"
-import { Features } from "../ui/landing/features"
-import { Markets } from "../ui/landing/markets"
-import { HowItWorks } from "../ui/landing/how-it-works"
-import { Infrastructure } from "../ui/landing/infrastructure"
-import { FinalCTA } from "../ui/landing/final-cta"
-import { Footer } from "../ui/landing/footer"
+import { HeaderMenu } from "../ui/landing/header-menu"
+import { HeroSection } from "../ui/landing/hero-section"
+import { LaunchSection } from "../ui/landing/launch-section"
+import { LiquiditySection } from "../ui/landing/liquidity-section"
+import { SponsorsSection } from "../ui/landing/sponsors-section"
+import { ProgramCards } from "../ui/landing/program-cards"
+import { FaqSection } from "../ui/landing/faq-section"
+import { RoadmapSection } from "../ui/landing/roadmap-section"
+import { SocialSection } from "../ui/landing/social-section"
export const Route = createFileRoute("/")({ component: LandingPage })
@@ -20,15 +20,15 @@ function LandingPage() {
// subtree regardless of the user's theme setting (docs/gf_3/001_theme_update.md §7),
// without touching so /trade, /pools, etc. keep honoring it.
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/web/src/styles/landing.css b/apps/web/src/styles/landing.css
index 695527b1..295103b0 100644
--- a/apps/web/src/styles/landing.css
+++ b/apps/web/src/styles/landing.css
@@ -1,22 +1,3 @@
-@keyframes pulseDot {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.4;
- }
-}
-
-@keyframes tickerSlide {
- 0% {
- transform: translateX(0);
- }
- 100% {
- transform: translateX(-50%);
- }
-}
-
/* Reduced motion: disable non-essential animations while preserving critical state changes */
@media (prefers-reduced-motion: reduce) {
*,
@@ -38,59 +19,6 @@
}
}
-/* Hero decorative gradients — adapt to primary token in both themes */
-.hero-glow::before {
- content: "";
- position: absolute;
- inset: 0;
- background:
- radial-gradient(
- 900px 480px at 78% 22%,
- color-mix(in oklch, var(--color-primary) 8%, transparent),
- transparent 60%
- ),
- radial-gradient(
- 800px 420px at 18% 70%,
- color-mix(in oklch, var(--color-primary) 4%, transparent),
- transparent 60%
- );
- pointer-events: none;
-}
-
-.hero-grid-bg {
- position: absolute;
- inset: 0;
- background-image:
- linear-gradient(
- color-mix(in oklch, var(--color-foreground) 4%, transparent) 1px,
- transparent 1px
- ),
- linear-gradient(
- 90deg,
- color-mix(in oklch, var(--color-foreground) 4%, transparent) 1px,
- transparent 1px
- );
- background-size: 56px 56px;
- mask-image: radial-gradient(
- ellipse 70% 60% at 50% 40%,
- #000 30%,
- transparent 80%
- );
- pointer-events: none;
-}
-
-.final-glow::before {
- content: "";
- position: absolute;
- inset: 0;
- background: radial-gradient(
- 800px 360px at 50% 60%,
- color-mix(in oklch, var(--color-primary) 7%, transparent),
- transparent 60%
- );
- pointer-events: none;
-}
-
/* Geist Mono with numeric tabular figures */
.font-mono-num {
font-family: "Geist Mono", ui-monospace, monospace;
diff --git a/apps/web/src/ui/landing/animated-title.tsx b/apps/web/src/ui/landing/animated-title.tsx
new file mode 100644
index 00000000..68923dd4
--- /dev/null
+++ b/apps/web/src/ui/landing/animated-title.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef, useState } from "react"
+
+// TODO(GF3-003): swap the rotation copy for SO4-specific markets (keep GMX's
+// first/last per the spec — final wording lands with GF3-003).
+const ROTATING_WORDS = [
+ "with 100x leverage",
+ "100+ crypto tokens",
+ "multiple asset classes",
+ "deep liquid markets",
+ "from 7 blockchains",
+]
+
+const HOLD_MS = 2500
+const TRANSITION_MS = 250
+
+export function AnimatedTitle() {
+ const [index, setIndex] = useState(0)
+ // "in" plays on every word change except the very first mount (no
+ // entrance animation needed before the reader has seen anything yet —
+ // this also keeps first paint deterministic for visual-regression tests,
+ // since Playwright's animation freeze can't reliably override an inline
+ // `style.animation` referencing a custom property).
+ const [phase, setPhase] = useState<"idle" | "out" | "in">("idle")
+ const reducedMotionRef = useRef(false)
+
+ useEffect(() => {
+ reducedMotionRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ }, [])
+
+ useEffect(() => {
+ if (reducedMotionRef.current) return
+
+ const holdTimer = setInterval(() => {
+ setPhase("out")
+ const outTimer = setTimeout(() => {
+ setIndex((i) => (i + 1) % ROTATING_WORDS.length)
+ setPhase("in")
+ }, TRANSITION_MS)
+ return () => clearTimeout(outTimer)
+ }, HOLD_MS)
+
+ return () => clearInterval(holdTimer)
+ }, [])
+
+ return (
+
+
+ {ROTATING_WORDS[index]}
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/faq-section.tsx b/apps/web/src/ui/landing/faq-section.tsx
new file mode 100644
index 00000000..e4cff3ad
--- /dev/null
+++ b/apps/web/src/ui/landing/faq-section.tsx
@@ -0,0 +1,86 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@workspace/ui/components/accordion"
+import type { ReactNode } from "react"
+
+// TODO(GF3-003): real copy — same shape as GMX (first answer bulleted,
+// second numbered), SO4-specific wording.
+const FAQS: Array<{ id: string; question: string; answer: ReactNode }> = [
+ {
+ id: "yield",
+ question: "What makes so4 one of the best places to earn yield on my crypto?",
+ answer: (
+
+ TODO(GF3-003): reason one
+ TODO(GF3-003): reason two
+ TODO(GF3-003): reason three
+
+ ),
+ },
+ {
+ id: "get-started",
+ question: "How do I get started on so4?",
+ answer: (
+
+ TODO(GF3-003): step one
+ TODO(GF3-003): step two
+ TODO(GF3-003): step three
+
+ ),
+ },
+ {
+ id: "cost-efficiency",
+ question: "What makes so4 more cost-efficient than other perpetual platforms?",
+ answer: TODO(GF3-003): answer copy.
,
+ },
+ {
+ id: "integrate",
+ question: "Can I build on top of so4 or integrate it into my DeFi app?",
+ answer: (
+
+ TODO(GF3-003): answer copy, with a link to{" "}
+
+ developer docs
+
+ .
+
+ ),
+ },
+]
+
+export function FaqSection() {
+ return (
+
+
+
FAQ
+
+ {/* The shared accordion already implements GMX's expand mechanic —
+ a grid-rows 0fr→1fr transition, no JS height measurement — plus
+ the aria-controls/labelledby wiring and focus-visible ring. Only
+ the landing's typography and hairline rules are restyled here. */}
+
+ {FAQS.map(({ id, question, answer }) => (
+
+
+ {question}
+
+
+ {answer}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/feature-grid.tsx b/apps/web/src/ui/landing/feature-grid.tsx
new file mode 100644
index 00000000..c556d527
--- /dev/null
+++ b/apps/web/src/ui/landing/feature-grid.tsx
@@ -0,0 +1,123 @@
+import { Link } from "@tanstack/react-router"
+import { Icon } from "@workspace/ui/components/icon"
+import { Tick02Icon } from "@hugeicons/core-free-icons"
+import { IconBox } from "./icon-box"
+
+const CHIPS = ["No deposits required", "Trade from your wallet", "No loss of fund ownership"]
+
+function GearsIcon() {
+ return (
+
+
+
+
+
+ )
+}
+
+function ShieldIcon() {
+ return (
+
+
+
+ )
+}
+
+export function FeatureGrid() {
+ return (
+
+ {/* Guaranteed liquidity */}
+
+
+
+
+
+
Trade with confidence
+
Guaranteed liquidity
+
+ Benefit from up to 100x leverage and guaranteed on-chain liquidity that's not dependent
+ on order book depth.
+
+
+
+
+ {/* Stay safe from liquidations — blue card, spans 2 rows */}
+
+
+
+
+
+
Stay safe from liquidations
+
+ Avoid price wicks with transparent, sub-second Chainlink price feeds tailor-made for so4.
+
+
+ {/* TODO(GF3-003): replace with the real protection-shield illustration */}
+
+
+
+ {/* Support for numerous assets — spans 2 rows */}
+
+
+
+
+
+
+
+
+
+
+
Support for numerous assets
+
Use your preferred token to pay and collateralize positions.
+
+ {/* TODO(GF3-003): replace with the real chain-icon cluster illustration */}
+
+ {Array.from({ length: 7 }).map((_, i) => (
+
+ ))}
+
+
+
+ {/* Save on costs */}
+
+
+
+
+
+
Keep more of what you earn
+
Save on costs
+
+ Trade at scale without worrying about thin order books or slippage.
+
+
+
+
+ {/* Secure & permissionless */}
+
+
Secure & permissionless
+
+ {CHIPS.map((chip) => (
+
+
+ {chip}
+
+ ))}
+
+
+
+ {/* Seamless trading — wide CTA card, spans 2 cols */}
+
+
Seamless trading
+
+ Enjoy a frictionless trading experience with One-Click Trading and Express Trading.
+
+
+ Trade now
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/features.tsx b/apps/web/src/ui/landing/features.tsx
deleted file mode 100644
index 06625da3..00000000
--- a/apps/web/src/ui/landing/features.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-const FEATURES = [
- {
- icon: (
-
-
-
- ),
- title: "On-chain orderbook",
- body: "Every quote, fill, and liquidation lands in a verifiable block. No off-chain matching engine, no batch tricks — just a transparent ledger you can audit.",
- statKey: "Median latency",
- statVal: "38ms",
- },
- {
- icon: (
-
-
-
- ),
- title: "Up to 50× leverage",
- body: "Cross- and isolated-margin modes, per-market caps, and a partial-liquidation engine that protects healthy positions during volatility spikes.",
- statKey: "Max leverage",
- statVal: "50×",
- },
- {
- icon: (
-
-
-
- ),
- title: "Self-custodied",
- body: "Your keys, your collateral. No deposits to a custodian, no withdrawal queues. Pull your margin on the same block you close a position.",
- statKey: "Withdraw time",
- statVal: "< 1 block",
- },
- {
- icon: (
-
-
-
- ),
- title: "Sub-second matching",
- body: "A purpose-built sequencer commits orders in 200ms blocks. Cancels and replaces are first-class — no priority gas auctions to game.",
- statKey: "Block time",
- statVal: "200ms",
- },
- {
- icon: (
-
-
-
-
- ),
- title: "Real yield to LPs",
- body: "Liquidity providers earn the trading fees and the funding spread, paid block-by-block. No emissions, no lockups, no vesting cliffs.",
- statKey: "30d APY",
- statVal: "18.4%",
- },
- {
- icon: (
-
-
-
-
-
-
- ),
- title: "One pool, every market",
- body: "BTC, ETH, SOL, the long-tail, FX perps and commodity perps all draw from the same balance sheet. Capital you don't deploy still earns.",
- statKey: "Live markets",
- statVal: "184",
- },
-]
-
-export function Features() {
- return (
-
-
- {/* Section header */}
-
-
-
- Engine
-
-
- Built for traders{" "}
- who care {" "}
- where their fills come from.
-
-
- so4 runs a unified liquidity layer — one pool backs every market,
- with deterministic settlement on every fill. No hidden routes, no
- opaque MM rebates.
-
-
-
- {/* Feature grid */}
-
- {FEATURES.map(({ icon, title, body, statKey, statVal }) => (
-
-
- {icon}
-
-
{title}
-
{body}
-
-
- {statKey}
-
- {statVal}
-
-
- ))}
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/final-cta.tsx b/apps/web/src/ui/landing/final-cta.tsx
deleted file mode 100644
index ddde7363..00000000
--- a/apps/web/src/ui/landing/final-cta.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Button } from "@workspace/ui/components/button"
-
-export function FinalCTA() {
- return (
-
-
-
- The book is{" "}
- open.
-
- You're a wallet away.
-
-
-
- No signup. No email. No deposit minimum. Connect and trade — or fork the
- contracts and run your own venue.
-
-
-
-
- Launch trading app →
-
-
- Read the docs
-
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/footer.tsx b/apps/web/src/ui/landing/footer.tsx
deleted file mode 100644
index 56b00338..00000000
--- a/apps/web/src/ui/landing/footer.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-const FOOTER_COLS = [
- {
- heading: "Product",
- links: ["Trade", "Earn", "Vaults", "Stats", "Leaderboard"],
- },
- {
- heading: "Developers",
- links: ["Documentation", "API reference", "SDK", "Contracts", "Bug bounty"],
- },
- {
- heading: "Support",
- links: ["FAQ", "Status", "Feedback", "Audits"],
- },
- {
- heading: "About",
- links: ["Blog", "Brand kit", "Litepaper", "Terms", "Privacy"],
- },
-]
-
-function SocialLinks() {
- const socials = [
- {
- label: "X",
- icon: (
-
-
-
- ),
- },
- {
- label: "Discord",
- icon: (
-
-
-
-
-
- ),
- },
- {
- label: "Telegram",
- icon: (
-
-
-
- ),
- },
- {
- label: "Mirror",
- icon: (
-
-
-
-
- ),
- },
- {
- label: "GitHub",
- icon: (
-
-
-
-
- ),
- },
- ]
-
- return (
-
- {socials.map(({ label, icon }) => (
-
- {icon}
-
- ))}
-
- )
-}
-
-export function Footer() {
- return (
-
- )
-}
diff --git a/apps/web/src/ui/landing/header-menu.tsx b/apps/web/src/ui/landing/header-menu.tsx
new file mode 100644
index 00000000..676b3530
--- /dev/null
+++ b/apps/web/src/ui/landing/header-menu.tsx
@@ -0,0 +1,160 @@
+import { useEffect, useRef } from "react"
+import { Link } from "@tanstack/react-router"
+import {
+ HamburgerButton,
+ SiteLogo,
+ useMobileMenu,
+} from "../nav/primitives"
+
+const NAV_LINKS: Array<{ label: string; to: "/trade" | "/pools" | "/earn" | "/referrals" }> = [
+ { label: "Trade", to: "/trade" },
+ { label: "Pools", to: "/pools" },
+ { label: "Earn", to: "/earn" },
+ { label: "Referrals", to: "/referrals" },
+]
+
+const SOCIALS = [
+ { label: "X", href: "#" },
+ { label: "Discord", href: "#" },
+ { label: "Telegram", href: "#" },
+ { label: "GitHub", href: "#" },
+]
+
+// "Open app" navigates, so it is a link styled as a button rather than a
+// — using Base UI's Button with render={ } strips native
+// button semantics and warns about it.
+function OpenAppButton({ className }: { className?: string }) {
+ return (
+
+ Open app
+
+ )
+}
+
+export function HeaderMenu() {
+ const { open, toggle, close } = useMobileMenu()
+ const panelRef = useRef(null)
+
+ // The mobile menu is a full-screen overlay, so it needs the two things
+ // an overlay owes a keyboard/screen-reader user beyond Escape-to-close
+ // (which useMobileMenu already handles): the page behind it must not
+ // scroll, and Tab must not walk out of it into that inert content.
+ useEffect(() => {
+ if (!open) return
+
+ const { body } = document
+ const previousOverflow = body.style.overflow
+ body.style.overflow = "hidden"
+
+ const panel = panelRef.current
+ panel?.querySelector("a, button")?.focus()
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Tab" || !panel) return
+
+ const focusables = panel.querySelectorAll(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ )
+ if (focusables.length === 0) return
+
+ const first = focusables[0]
+ const last = focusables[focusables.length - 1]
+
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ document.addEventListener("keydown", onKeyDown)
+ return () => {
+ document.removeEventListener("keydown", onKeyDown)
+ body.style.overflow = previousOverflow
+ }
+ }, [open])
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/ui/landing/hero-section.tsx b/apps/web/src/ui/landing/hero-section.tsx
new file mode 100644
index 00000000..5f54dc90
--- /dev/null
+++ b/apps/web/src/ui/landing/hero-section.tsx
@@ -0,0 +1,82 @@
+import { Link } from "@tanstack/react-router"
+import { AnimatedTitle } from "./animated-title"
+import { FeatureGrid } from "./feature-grid"
+import { useLandingStats } from "./use-landing-stats"
+import { shortFormat, shortFormatUsd } from "./utils/formatters"
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
+
+function VolumeStat({ value }: { value: string }) {
+ return (
+
+
+ Total volume
+ →
+
+ {value}
+
+ )
+}
+
+export function HeroSection() {
+ const stats = useLandingStats()
+
+ return (
+
+ {/* TODO(GF3-003): replace with the real chain-constellation background animation */}
+
+
+
+
+ Trade from your wallet
+
+
+ {/* The CTA + subheadline + 3-stat group only fit on one line once
+ there is desktop room for them, so the row is deferred to `lg`.
+ Between 640 and 1023 the stats sit on their own line instead of
+ being squeezed into a third column (which wrapped every stat
+ label onto three lines at 768). */}
+
+
+
+ Trade now
+
+
+
+ Decentralised permissionless on-chain exchange with deep liquidity and low costs, live
+ since 2026.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/hero.tsx b/apps/web/src/ui/landing/hero.tsx
deleted file mode 100644
index 4d59ae17..00000000
--- a/apps/web/src/ui/landing/hero.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-import { useEffect, useState } from "react"
-import { Button } from "@workspace/ui/components/button"
-import { Ticker } from "./ticker"
-
-/* ─── Order Book ─────────────────────────────────────────── */
-const ASKS = [
- { price: "67,224.0", size: "0.842", total: "5.218", w: "42%" },
- { price: "67,222.5", size: "1.204", total: "4.376", w: "55%" },
- { price: "67,221.0", size: "0.318", total: "3.172", w: "18%" },
- { price: "67,220.0", size: "2.108", total: "2.854", w: "78%" },
- { price: "67,219.5", size: "0.624", total: "0.746", w: "32%" },
-]
-const BIDS = [
- { price: "67,218.0", size: "1.418", total: "1.418", w: "60%" },
- { price: "67,217.5", size: "0.502", total: "1.920", w: "22%" },
- { price: "67,216.0", size: "2.348", total: "4.268", w: "85%" },
- { price: "67,214.5", size: "0.918", total: "5.186", w: "42%" },
- { price: "67,213.0", size: "1.124", total: "6.310", w: "50%" },
-]
-
-function OrderBook() {
- return (
-
- {/* Header */}
-
- Price
- Size
- Total
-
-
- {/* Asks */}
- {ASKS.map((row) => (
-
-
-
{row.price}
-
{row.size}
-
{row.total}
-
- ))}
-
- {/* Spread */}
-
- Spread
- 0.50
- 0.0007%
-
-
- {/* Bids */}
- {BIDS.map((row) => (
-
-
-
{row.price}
-
{row.size}
-
{row.total}
-
- ))}
-
- )
-}
-
-/* ─── Mini Chart ─────────────────────────────────────────── */
-function MiniChart({ priceLabel }: { priceLabel: string }) {
- return (
-
- {/* Live price tag */}
-
- {priceLabel}
-
-
- {/* Y-axis labels */}
-
- 67.4k
- 67.0k
- 66.6k
- 66.2k
-
-
-
-
-
-
-
-
-
-
- {/* Grid lines */}
-
-
-
-
- {/* Candle bodies */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Price area fill */}
-
-
- {/* Price line */}
-
-
- {/* Last price dot */}
-
-
-
-
- {/* X-axis labels */}
-
- 09:00
- 11:00
- 13:00
- 15:00
- 17:00
-
-
- )
-}
-
-/* ─── Trading Card ───────────────────────────────────────── */
-function TradingCard() {
- const [price, setPrice] = useState(67218.4)
-
- useEffect(() => {
- let base = 67218.4
- const id = setInterval(() => {
- const delta = (Math.random() - 0.45) * 4
- base = Math.max(67100, Math.min(67320, base + delta))
- setPrice(base)
- }, 1600)
- return () => clearInterval(id)
- }, [])
-
- const fmt = price.toLocaleString("en-US", {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })
-
- return (
-
- {/* Card header */}
-
-
-
- B
-
-
-
- BTC-PERP
-
-
- ${fmt}
-
-
-
- +2.41%
-
-
-
-
- {[
- { label: "24h Vol", value: "$2.18B" },
- { label: "OI", value: "$612M" },
- { label: "Funding", value: "+0.0098%" },
- ].map(({ label, value }) => (
-
- ))}
-
-
-
- {/* Card body: chart + order book */}
-
-
-
-
-
- {/* Card footer: buy/sell */}
-
-
- Buy / Long
-
-
- Sell / Short
-
-
-
- )
-}
-
-/* ─── Hero ───────────────────────────────────────────────── */
-export function Hero() {
- return (
-
-
-
-
- {/* Left column */}
-
-
-
- Mainnet · v1.4 · Lagos UTC+1
-
-
-
- perpetual
-
- markets,{" "}
- settled
-
- on-chain.
-
-
-
- A unified-liquidity perp DEX. Deep books, sub-second matching, and
- self-custodied risk — built for traders who care where their fills
- come from.
-
-
-
-
- Start trading →
-
-
- Read the litepaper
-
-
-
-
-
- $8.42B 24h volume
-
-
-
- 184 markets
-
-
-
- 0.014% taker fee
-
-
-
-
- {/* Right column: trading card */}
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/how-it-works.tsx b/apps/web/src/ui/landing/how-it-works.tsx
deleted file mode 100644
index 8e1a1e3b..00000000
--- a/apps/web/src/ui/landing/how-it-works.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-const STEPS = [
- {
- num: "/ 01",
- title: "Connect",
- body: "Bring any EVM or Solana wallet. We verify the signature locally; no email, no password, no recovery flow to fail you.",
- lines: [
- { text: "$ ", accent: "so4", rest: " connect --wallet metamask" },
- { text: "→ signing nonce…" },
- { text: "→ ", ok: "verified", rest: " 0x4a...c19f" },
- { text: "▸ session opened" },
- ],
- },
- {
- num: "/ 02",
- title: "Deposit",
- body: "Move USDC into your margin account in a single transaction. Pull it back out the moment a position closes — no withdrawal queues.",
- lines: [
- { text: "▸ deposit 5,000.00 USDC" },
- { text: "→ tx 0x18a2…", accent: "e4f0" },
- { text: "→ ", ok: "confirmed", rest: " in 1 block" },
- { text: "▸ collateral active" },
- ],
- },
- {
- num: "/ 03",
- title: "Trade",
- body: "Limit, market, stop, scaled — one click hits the book. Fills are signed and settled the same block they're placed.",
- lines: [
- { text: "▸ long BTC-PERP 0.4 @ mkt" },
- { text: "→ filled 0.4 @ 67,218.40" },
- { text: "→ funding -0.0098% / 1h" },
- { text: "", ok: "▸ position open" },
- ],
- },
-]
-
-type TermLine = {
- text?: string
- accent?: string
- rest?: string
- ok?: string
-}
-
-function Terminal({ lines }: { lines: Array }) {
- return (
-
- {lines.map((line, i) => (
-
- {line.text}
- {line.accent && {line.accent} }
- {line.rest}
- {line.ok && {line.ok} }
-
- ))}
-
- )
-}
-
-export function HowItWorks() {
- return (
-
-
-
-
-
- Flow
-
-
- From wallet to fill in{" "}
- three steps.
-
-
- No KYC, no email, no signup form. Connect a wallet and the orderbook is
- open — your collateral never leaves your control.
-
-
-
-
- {STEPS.map(({ num, title, body, lines }) => (
-
-
- {num}
-
-
{title}
-
{body}
-
-
- ))}
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/icon-box.tsx b/apps/web/src/ui/landing/icon-box.tsx
new file mode 100644
index 00000000..28f33c07
--- /dev/null
+++ b/apps/web/src/ui/landing/icon-box.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react"
+
+export function IconBox({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/ui/landing/infrastructure.tsx b/apps/web/src/ui/landing/infrastructure.tsx
deleted file mode 100644
index 7c9d54da..00000000
--- a/apps/web/src/ui/landing/infrastructure.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-const INFRA = [
- {
- key: "Block time",
- val: "200",
- unit: "ms",
- desc: "Deterministic finality. No reorgs, no MEV auctions.",
- },
- {
- key: "Throughput",
- val: "200k",
- unit: "/s",
- desc: "Orders per second on a single sequencer; horizontally shardable.",
- },
- {
- key: "Settlement",
- val: "L1",
- unit: "",
- desc: "Native settlement; no bridge, no L2 withdrawal delay.",
- },
- {
- key: "Audits",
- val: "7",
- unit: "",
- desc: "By Trail of Bits, Zellic, OtterSec — full reports public.",
- },
-]
-
-export function Infrastructure() {
- return (
-
-
-
-
-
- Infrastructure
-
-
- An{" "}
- app-specific chain {" "}
- with one job: settle perps.
-
-
- so4 runs on a custom L1 tuned for orderbook throughput. We don't share
- blockspace with NFT mints or memecoin launches — your fill is the only
- thing in the queue.
-
-
-
-
- {INFRA.map(({ key, val, unit, desc }, i) => (
-
-
- {key}
-
-
- {val}
- {unit && (
- {unit}
- )}
-
-
{desc}
-
- ))}
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/launch-section.tsx b/apps/web/src/ui/landing/launch-section.tsx
new file mode 100644
index 00000000..9e88beea
--- /dev/null
+++ b/apps/web/src/ui/landing/launch-section.tsx
@@ -0,0 +1,49 @@
+import { Link } from "@tanstack/react-router"
+
+// TODO(GF3-003): confirm the final network list SO4 settles on and swap in
+// real chain logos.
+const NETWORKS = [
+ { name: "Stellar" },
+ { name: "Soroban" },
+]
+
+function LaunchButton({ name }: { name: string }) {
+ return (
+
+
+
+ {name.slice(0, 1)}
+
+ {name}
+
+ →
+
+ )
+}
+
+export function LaunchSection() {
+ return (
+
+
+
+
Runs entirely on public chains
+
+ Operates on open, permissionless networks to ensure transparency, decentralization, and
+ unrestricted access.
+
+
+ Open app
+
+
+
+ {NETWORKS.map((n) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/liquidity-section.tsx b/apps/web/src/ui/landing/liquidity-section.tsx
new file mode 100644
index 00000000..fd4c352f
--- /dev/null
+++ b/apps/web/src/ui/landing/liquidity-section.tsx
@@ -0,0 +1,40 @@
+import { Link } from "@tanstack/react-router"
+import { PoolCard } from "./pool-card"
+import { useLandingStats } from "./use-landing-stats"
+import { cleanFormatUsd } from "./utils/formatters"
+
+// TODO(GF3-003): source from SO4's pools API/indexer once available; these
+// mirror GMX's own example pools shape (name/description/APR) as placeholders.
+const POOLS = [
+ { name: "SO4", description: "Stake for rewards and governance rights", apr: null },
+ { name: "SLV", description: "Steady returns without management", apr: 0.1465 },
+ { name: "SM", description: "Invest with control over risk and reward", apr: 0.3582 },
+]
+
+export function LiquiditySection() {
+ const stats = useLandingStats()
+
+ return (
+
+
+
+ {stats.liquidityTotal === null ? "-" : cleanFormatUsd(stats.liquidityTotal)} in liquidity
+
+
+
+ {/* TODO(GF3-003): real user count once the indexer exposes it */}
+
Join our users earning real yield
+
+ Start earning
+
+
+
+
+ {POOLS.map((pool) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/markets.tsx b/apps/web/src/ui/landing/markets.tsx
deleted file mode 100644
index fe045295..00000000
--- a/apps/web/src/ui/landing/markets.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import { Button } from "@workspace/ui/components/button"
-
-const MARKETS = [
- {
- sym: "BTC-PERP",
- name: "Bitcoin",
- icon: "B",
- iconBg: "bg-gradient-to-br from-amber-500 to-amber-700",
- price: "$67,218.40",
- lev: "50×",
- change: "+2.41%",
- up: true,
- vol: "$2.18B",
- oi: "$612M",
- },
- {
- sym: "ETH-PERP",
- name: "Ethereum",
- icon: "E",
- iconBg: "bg-gradient-to-br from-indigo-400 to-indigo-700",
- price: "$3,482.16",
- lev: "40×",
- change: "+3.18%",
- up: true,
- vol: "$1.42B",
- oi: "$418M",
- },
- {
- sym: "SOL-PERP",
- name: "Solana",
- icon: "S",
- iconBg: "bg-gradient-to-br from-purple-500 to-emerald-500",
- price: "$182.04",
- lev: "25×",
- change: "−1.24%",
- up: false,
- vol: "$684M",
- oi: "$208M",
- },
- {
- sym: "HYPE-PERP",
- name: "Hyperliquid",
- icon: "H",
- iconBg: "bg-gradient-to-br from-blue-500 to-blue-900",
- price: "$28.41",
- lev: "20×",
- change: "+8.62%",
- up: true,
- vol: "$118M",
- oi: "$42.1M",
- },
- {
- sym: "AAPL-PERP",
- name: "Apple Inc.",
- icon: "A",
- iconBg: "bg-gradient-to-br from-yellow-400 to-yellow-600",
- iconDark: true,
- price: "$214.80",
- lev: "10×",
- change: "+0.42%",
- up: true,
- vol: "$22.1M",
- oi: "$8.4M",
- },
- {
- sym: "XAU-PERP",
- name: "Gold (oz)",
- icon: "X",
- iconBg: "bg-gradient-to-br from-yellow-600 to-yellow-800",
- iconDark: true,
- price: "$2,684.12",
- lev: "15×",
- change: "+0.18%",
- up: true,
- vol: "$48.2M",
- oi: "$22.8M",
- },
- {
- sym: "NGN-PERP",
- name: "Naira / USD",
- icon: "N",
- iconBg: "bg-gradient-to-br from-yellow-500 to-yellow-700",
- iconDark: true,
- price: "₦1,612.40",
- lev: "5×",
- change: "−0.62%",
- up: false,
- vol: "$4.1M",
- oi: "$1.2M",
- },
-]
-
-export function Markets() {
- return (
-
-
- {/* Section header */}
-
-
-
-
- Markets
-
-
- 184 perpetuals.{" "}
- One book.
-
-
- Crypto, FX, rates, and commodity perps — all settled in USDC, all
- backed by the same unified liquidity layer.
-
-
-
- View all markets →
-
-
-
- {/* Table */}
-
- {/* Header */}
-
- Market
- Price
- Max lev.
- 24h change
- 24h volume
- Open interest
-
-
-
- {MARKETS.map(({ sym, name, icon, iconBg, iconDark, price, lev, change, up, vol, oi }) => (
-
- {/* Mobile layout */}
-
-
-
-
{price}
-
- {change}
-
-
-
-
- {/* Desktop layout */}
-
-
-
-
- {price}
-
-
-
-
- {lev}
-
-
-
-
- {change}
-
-
-
- {vol}
-
-
-
- {oi}
-
-
-
- Trade →
-
-
-
- ))}
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/nav.tsx b/apps/web/src/ui/landing/nav.tsx
deleted file mode 100644
index 312c0e60..00000000
--- a/apps/web/src/ui/landing/nav.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useLocation } from "@tanstack/react-router"
-import { Button } from "@workspace/ui/components/button"
-import { ThemeToggle } from "../theme-toggle"
-import {
- HamburgerButton,
- SiteLogo,
- containerClass,
- desktopActiveLinkClass,
- desktopLinkClass,
- mobileActiveLinkClass,
- mobileLinkClass,
- navOuterClass,
- useMobileMenu,
-} from "../nav/primitives"
-import { ConnectButton } from "@/features/wallet/components/ConnectButton"
-
-const NAV_LINKS = [
- { label: "Trade", href: "/trade" },
- { label: "Earn", href: "/earn" },
- { label: "Stats", href: "#" },
- { label: "Docs", href: "#" },
- { label: "Governance", href: "#" },
-]
-
-export function Nav() {
- const { open, toggle, close } = useMobileMenu()
- const { pathname } = useLocation()
- const isActive = (href: string) => href !== "#" && pathname === href
-
- return (
-
-
-
-
- {/* Desktop nav links */}
-
- {NAV_LINKS.map(({ label, href }) => (
-
-
- {label}
-
-
- ))}
-
-
- {/* Actions */}
-
-
-
-
-
-
-
- {/* Mobile dropdown */}
- {open && (
-
-
- {NAV_LINKS.map(({ label, href }) => (
-
-
- {label}
-
-
- ))}
-
-
- Launch app →
-
-
- )}
-
- )
-}
diff --git a/apps/web/src/ui/landing/newsletter-form.tsx b/apps/web/src/ui/landing/newsletter-form.tsx
new file mode 100644
index 00000000..34c1c8da
--- /dev/null
+++ b/apps/web/src/ui/landing/newsletter-form.tsx
@@ -0,0 +1,25 @@
+import { Button } from "@workspace/ui/components/button"
+import { Input } from "@workspace/ui/components/input"
+
+// TODO(GF3-003): wire to SO4's real newsletter endpoint once one exists.
+export function NewsletterForm() {
+ return (
+
+ )
+}
diff --git a/apps/web/src/ui/landing/pool-card.tsx b/apps/web/src/ui/landing/pool-card.tsx
new file mode 100644
index 00000000..543b4751
--- /dev/null
+++ b/apps/web/src/ui/landing/pool-card.tsx
@@ -0,0 +1,41 @@
+import { IconBox } from "./icon-box"
+import { percentFormat } from "./utils/formatters"
+
+export type PoolCardData = {
+ name: string
+ description: string
+ apr: number | null
+}
+
+function CoinIcon() {
+ return (
+
+
+
+
+ )
+}
+
+export function PoolCard({ name, description, apr }: PoolCardData) {
+ return (
+
+ {/* TODO(GF3-003): replace with the real gradient cover + parallax lines + coin illustration */}
+
+
+
+
+
+
+
{name}
+
{description}
+
+
+
+ {apr === null ? "Accumulating..." : percentFormat(apr)}
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/program-cards.tsx b/apps/web/src/ui/landing/program-cards.tsx
new file mode 100644
index 00000000..a6a61d63
--- /dev/null
+++ b/apps/web/src/ui/landing/program-cards.tsx
@@ -0,0 +1,71 @@
+import { Link } from "@tanstack/react-router"
+
+function EyebrowPill({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function ProgramCards() {
+ return (
+
+ {/* TODO(GF3-003): replace with the real home_program_glow.png equivalent */}
+
+
+
+
+ Built for those
+
+ who do more.
+
+
+
+ {/* Card 1: VIP / referrals */}
+
+
+ For large traders
+ For affiliates
+
+
Trade size. Or refer those who do.
+
+ Up to 25% off fees for high-volume traders. Up to 25% earnings for affiliates. Plus
+ personal support.
+
+
+ Explore the referrals program
+
+
+
+ {/* Card 2: developers */}
+
+
+ For developers & teams
+ Integrate SO4
+
+
Build on so4. Get paid on every trade.
+
+ Onchain revenue in stablecoins — your own builder fee plus a cut of so4's fees,
+ every time someone trades through you.
+
+ {/* TODO(GF3-003): link to real developer docs once published */}
+
+ Explore the developer program
+
+ {/* TODO(GF3-003): replace with the real CodeSnippet panel */}
+
+ sdk.execute(...)
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/quarter.tsx b/apps/web/src/ui/landing/quarter.tsx
new file mode 100644
index 00000000..01c6f705
--- /dev/null
+++ b/apps/web/src/ui/landing/quarter.tsx
@@ -0,0 +1,30 @@
+export type QuarterItem = { text: string; completed: boolean }
+
+export type QuarterData = {
+ label: string
+ items: Array
+ lastCompleted?: boolean
+}
+
+export function Quarter({ label, items, lastCompleted }: QuarterData) {
+ return (
+
+
+ {lastCompleted && (
+ <>
+
+
+ >
+ )}
+
+
{label}
+
+ {items.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/roadmap-section.tsx b/apps/web/src/ui/landing/roadmap-section.tsx
new file mode 100644
index 00000000..7518c5de
--- /dev/null
+++ b/apps/web/src/ui/landing/roadmap-section.tsx
@@ -0,0 +1,74 @@
+import { Quarter } from "./quarter"
+import type { QuarterData } from "./quarter"
+
+// TODO(GF3-003): real milestones/dates for SO4.
+const QUARTERS: Array = [
+ {
+ label: "Q1",
+ items: [
+ { text: "Testnet launch", completed: true },
+ { text: "Core protocol audit", completed: true },
+ ],
+ lastCompleted: true,
+ },
+ {
+ label: "Q2",
+ items: [
+ { text: "Mainnet launch", completed: false },
+ { text: "Referrals program", completed: false },
+ ],
+ },
+ {
+ label: "Q3",
+ items: [
+ { text: "Cross-margin", completed: false },
+ { text: "Additional markets", completed: false },
+ ],
+ },
+ {
+ label: "Q4",
+ items: [
+ { text: "Governance", completed: false },
+ { text: "Ecosystem grants", completed: false },
+ ],
+ },
+]
+
+export function RoadmapSection() {
+ return (
+
+
+
+
Roadmap
+ {/* TODO(GF3-003): link to the real dev-plan writeup */}
+
+ Read more
+
+
+
+ {/* tabIndex makes the horizontal scroller reachable by keyboard —
+ a scroll container is only arrow-key scrollable once focused,
+ and without this the roadmap is unreachable without a pointer.
+ role/aria-label give it a name in the a11y tree now that it is
+ a focus stop. */}
+
+ {QUARTERS.map((q) => (
+
+ ))}
+
+
+
+ Read more
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/social-section.tsx b/apps/web/src/ui/landing/social-section.tsx
new file mode 100644
index 00000000..4c97f765
--- /dev/null
+++ b/apps/web/src/ui/landing/social-section.tsx
@@ -0,0 +1,64 @@
+import { NewsletterForm } from "./newsletter-form"
+import { SocialSlider } from "./social-slider"
+
+// TODO(GF3-003): real counts + URLs once socials are set up.
+const SOCIAL_STATS = [
+ { name: "Discord", value: "-", href: "#" },
+ { name: "X", value: "-", href: "#" },
+ { name: "Telegram", value: "-", href: "#" },
+ { name: "GitHub", value: "Join", href: "#" },
+]
+
+const FOOTER_LINKS = [
+ { label: "Referral terms", href: "#" },
+ { label: "Media kit", href: "#" },
+ { label: "Terms and conditions", href: "#" },
+]
+
+export function SocialSection() {
+ return (
+
+
+
+
+
+
+
+ Driven by
+
+ our community.
+
+
+
+
+
+ {FOOTER_LINKS.map(({ label, href }) => (
+
+ {label}
+
+ ))}
+
+ Charts by TradingView
+
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/social-slider.tsx b/apps/web/src/ui/landing/social-slider.tsx
new file mode 100644
index 00000000..4acae563
--- /dev/null
+++ b/apps/web/src/ui/landing/social-slider.tsx
@@ -0,0 +1,35 @@
+// TODO(GF3-003): curated community tweets/testimonials (static content, no X API).
+const CARDS = [
+ { handle: "@trader_one", text: "TODO(GF3-003): testimonial copy." },
+ { handle: "@trader_two", text: "TODO(GF3-003): testimonial copy." },
+ { handle: "@trader_three", text: "TODO(GF3-003): testimonial copy." },
+]
+
+function SocialCard({ handle, text }: { handle: string; text: string }) {
+ return (
+
+
+
+ {handle}
+
+
{text}
+
+ )
+}
+
+export function SocialSlider() {
+ const doubled = [...CARDS, ...CARDS]
+
+ return (
+
+
+ {doubled.map((card, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/sponsors-section.tsx b/apps/web/src/ui/landing/sponsors-section.tsx
new file mode 100644
index 00000000..5ada0955
--- /dev/null
+++ b/apps/web/src/ui/landing/sponsors-section.tsx
@@ -0,0 +1,27 @@
+// TODO(GF3-003): swap in real partner/infra SVG logos.
+const SPONSORS = ["Stellar", "Soroban", "Reflector", "Blend"]
+
+export function SponsorsSection() {
+ return (
+
+
+
+
Supported by
+
+ Stellar & Soroban ecosystem partners
+
+
+
+ {SPONSORS.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/ui/landing/stats.tsx b/apps/web/src/ui/landing/stats.tsx
deleted file mode 100644
index 6357d51c..00000000
--- a/apps/web/src/ui/landing/stats.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-const STATS = [
- {
- label: "Cumulative Volume",
- value: "184.62",
- pre: "$",
- suf: "B",
- delta: "+$8.42B · 24h",
- down: false,
- },
- {
- label: "Open Interest",
- value: "2.41",
- pre: "$",
- suf: "B",
- delta: "+4.18% · 24h",
- down: false,
- },
- {
- label: "Active Traders",
- value: "214,802",
- pre: "",
- suf: "",
- delta: "+1,284 · 24h",
- down: false,
- },
- {
- label: "Pool TVL",
- value: "418.7",
- pre: "$",
- suf: "M",
- delta: "−0.42% · 24h",
- down: true,
- },
-]
-
-export function Stats() {
- return (
-
-
-
- {STATS.map(({ label, value, pre, suf, delta, down }, i) => (
-
-
-
- {label}
-
-
- {pre && {pre} }
- {value}
- {suf && {suf} }
-
-
- {delta}
-
-
- ))}
-
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/ticker.tsx b/apps/web/src/ui/landing/ticker.tsx
deleted file mode 100644
index 2e08c684..00000000
--- a/apps/web/src/ui/landing/ticker.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-const TICKERS = [
- ["BTC-PERP", "67,218.40", "+2.41%", "up"],
- ["ETH-PERP", "3,482.16", "+3.18%", "up"],
- ["SOL-PERP", "182.04", "−1.24%", "dn"],
- ["HYPE-PERP", "28.41", "+8.62%", "up"],
- ["ARB-PERP", "0.842", "−0.62%", "dn"],
- ["XAU-PERP", "2,684.12", "+0.18%", "up"],
- ["DOGE-PERP", "0.142", "+4.12%", "up"],
- ["SUI-PERP", "2.184", "+1.82%", "up"],
- ["AAPL-PERP", "214.80", "+0.42%", "up"],
- ["NGN-PERP", "1,612.40", "−0.62%", "dn"],
- ["AVAX-PERP", "42.18", "+2.04%", "up"],
- ["LINK-PERP", "18.42", "−0.84%", "dn"],
- ["TON-PERP", "6.28", "+1.24%", "up"],
- ["DOT-PERP", "7.42", "+0.42%", "up"],
-] as const
-
-function TickerItem({ sym, px, ch, dir }: { sym: string; px: string; ch: string; dir: string }) {
- return (
-
- {sym}
- ${px}
-
- {ch}
-
-
- )
-}
-
-export function Ticker() {
- const doubled = [...TICKERS, ...TICKERS]
-
- return (
-
-
- {doubled.map(([sym, px, ch, dir], i) => (
-
- ))}
-
-
- )
-}
diff --git a/apps/web/src/ui/landing/use-landing-stats.ts b/apps/web/src/ui/landing/use-landing-stats.ts
new file mode 100644
index 00000000..bc9df68d
--- /dev/null
+++ b/apps/web/src/ui/landing/use-landing-stats.ts
@@ -0,0 +1,23 @@
+// TODO(GF3-003): wire to SO4's indexer/stats API. GMX sources these from
+// useTraders/useTotalVolume/usePoolsData (landing/src/pages/Home/hooks/*);
+// SO4 has no aggregate stats endpoint yet, so every field renders the "-"
+// loading placeholder — matching GMX's own loading state exactly, never a
+// fabricated number.
+
+export type LandingStats = {
+ traders: number | null
+ openInterest: number | null
+ totalVolume: number | null
+ liquidityTotal: number | null
+ loading: boolean
+}
+
+export function useLandingStats(): LandingStats {
+ return {
+ traders: null,
+ openInterest: null,
+ totalVolume: null,
+ liquidityTotal: null,
+ loading: false,
+ }
+}
diff --git a/apps/web/src/ui/landing/utils/formatters.ts b/apps/web/src/ui/landing/utils/formatters.ts
new file mode 100644
index 00000000..f9e99e10
--- /dev/null
+++ b/apps/web/src/ui/landing/utils/formatters.ts
@@ -0,0 +1,30 @@
+// TODO(GF3-003): verify against real indexer-scale numbers once wired up.
+
+/** 230000 -> "230K", 5_200_000 -> "5.2M", 1_400_000_000 -> "1.4B" */
+export function shortFormat(value: number): string {
+ const abs = Math.abs(value)
+ if (abs >= 1_000_000_000) return `${trimZero(value / 1_000_000_000)}B`
+ if (abs >= 1_000_000) return `${trimZero(value / 1_000_000)}M`
+ if (abs >= 1_000) return `${trimZero(value / 1_000)}K`
+ return String(Math.round(value))
+}
+
+/** 230000 -> "$230K" */
+export function shortFormatUsd(value: number): string {
+ return `$${shortFormat(value)}`
+}
+
+/** 157000000 -> "$157 000 000" (space-grouped thousands, GMX's liquidity total style) */
+export function cleanFormatUsd(value: number): string {
+ const rounded = Math.round(value)
+ return `$${rounded.toLocaleString("en-US").replace(/,/g, " ")}`
+}
+
+/** 0.1465 -> "14.65%" */
+export function percentFormat(value: number): string {
+ return `${(value * 100).toFixed(2)}%`
+}
+
+function trimZero(value: number): string {
+ return value % 1 === 0 ? String(value) : value.toFixed(1)
+}
diff --git a/e2e/design-system-visual.spec.ts b/e2e/design-system-visual.spec.ts
index 1deba963..bfa17a70 100644
--- a/e2e/design-system-visual.spec.ts
+++ b/e2e/design-system-visual.spec.ts
@@ -60,13 +60,24 @@ async function stubExternalNetwork(page: Page) {
for (const theme of THEMES) {
for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) {
test.describe(`${theme} theme, ${viewportName}`, () => {
- test.use({ viewport })
+ // reducedMotion: "reduce" *should* stop JS-timer-driven animations
+ // that `animations: "disabled"` can't freeze (e.g. the landing
+ // hero's word-rotation interval and the community marquee), but
+ // this Chrome Headless Shell build doesn't reflect it in
+ // matchMedia() or CSS @media queries (verified: both read false
+ // even with this set) — kept anyway as the semantically correct
+ // setting for engines that do honor it. The actual freeze comes
+ // from page.clock below, which stops those timers from ever firing
+ // by fixing the clock before any script runs.
+ test.use({ viewport, reducedMotion: "reduce" })
test.beforeEach(async ({ page }) => {
await stubExternalNetwork(page)
await page.addInitScript((t) => {
window.localStorage.setItem("so4-theme", t)
}, theme)
+ await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") })
+ await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"))
})
for (const route of ROUTES) {
@@ -90,7 +101,7 @@ 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.use({ viewport, reducedMotion: "reduce" })
test.beforeEach(async ({ page }) => {
await stubExternalNetwork(page)
diff --git a/e2e/design-system-visual.spec.ts-snapshots/landing-dark-desktop-chromium-linux.png b/e2e/design-system-visual.spec.ts-snapshots/landing-dark-desktop-chromium-linux.png
index 3555a191..670a5815 100644
Binary files a/e2e/design-system-visual.spec.ts-snapshots/landing-dark-desktop-chromium-linux.png and b/e2e/design-system-visual.spec.ts-snapshots/landing-dark-desktop-chromium-linux.png differ
diff --git a/e2e/design-system-visual.spec.ts-snapshots/landing-dark-mobile-chromium-linux.png b/e2e/design-system-visual.spec.ts-snapshots/landing-dark-mobile-chromium-linux.png
index 2d009e82..6e93d7a7 100644
Binary files a/e2e/design-system-visual.spec.ts-snapshots/landing-dark-mobile-chromium-linux.png and b/e2e/design-system-visual.spec.ts-snapshots/landing-dark-mobile-chromium-linux.png differ
diff --git a/e2e/design-system-visual.spec.ts-snapshots/landing-light-desktop-chromium-linux.png b/e2e/design-system-visual.spec.ts-snapshots/landing-light-desktop-chromium-linux.png
index cc1a0f2c..670a5815 100644
Binary files a/e2e/design-system-visual.spec.ts-snapshots/landing-light-desktop-chromium-linux.png and b/e2e/design-system-visual.spec.ts-snapshots/landing-light-desktop-chromium-linux.png differ
diff --git a/e2e/design-system-visual.spec.ts-snapshots/landing-light-mobile-chromium-linux.png b/e2e/design-system-visual.spec.ts-snapshots/landing-light-mobile-chromium-linux.png
index da399fb6..6e93d7a7 100644
Binary files a/e2e/design-system-visual.spec.ts-snapshots/landing-light-mobile-chromium-linux.png and b/e2e/design-system-visual.spec.ts-snapshots/landing-light-mobile-chromium-linux.png differ
diff --git a/e2e/landing-a11y-check.spec.ts b/e2e/landing-a11y-check.spec.ts
new file mode 100644
index 00000000..c6096630
--- /dev/null
+++ b/e2e/landing-a11y-check.spec.ts
@@ -0,0 +1,53 @@
+import { expect, test } from "@playwright/test"
+
+test.use({ viewport: { width: 390, height: 844 } })
+
+test("faq accordion opens via keyboard and is wired to its panel", async ({ page }) => {
+ await page.goto("/")
+ await page.waitForLoadState("networkidle")
+ const trigger = page.getByRole("button", { name: /best places to earn yield/i })
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
+ const controls = await trigger.getAttribute("aria-controls")
+ expect(controls).toBeTruthy()
+
+ // Retry past hydration: the trigger is server-rendered and only starts
+ // responding to Enter once React has attached its handler.
+ await expect(async () => {
+ await trigger.focus()
+ await page.keyboard.press("Enter")
+ await expect(trigger).toHaveAttribute("aria-expanded", "true", { timeout: 1000 })
+ }).toPass({ timeout: 15_000 })
+ await expect(page.locator(`#${controls}`)).toHaveAttribute("aria-hidden", "false")
+})
+
+test("roadmap scroller is keyboard reachable", async ({ page }) => {
+ await page.goto("/")
+ await page.waitForLoadState("networkidle")
+ const scroller = page.getByRole("group", { name: /roadmap timeline/i })
+ await expect(scroller).toHaveAttribute("tabindex", "0")
+ await scroller.focus()
+ await expect(scroller).toBeFocused()
+})
+
+test("mobile menu is a modal dialog and locks body scroll", async ({ page }) => {
+ await page.goto("/")
+ await page.waitForLoadState("networkidle")
+
+ // The burger is server-rendered, so it is clickable before React has
+ // attached its onClick. Retry the open until the panel actually appears
+ // rather than racing hydration.
+ const burger = page.getByRole("button", { name: /open menu/i })
+ const dialog = page.locator('[role="dialog"]')
+ await expect(async () => {
+ await burger.click()
+ await expect(dialog).toBeVisible({ timeout: 1000 })
+ }).toPass({ timeout: 15_000 })
+
+ await expect(dialog).toHaveAttribute("aria-modal", "true")
+ await expect(dialog).toHaveAttribute("aria-label", "Site menu")
+ expect(await page.evaluate(() => document.body.style.overflow)).toBe("hidden")
+
+ await page.keyboard.press("Escape")
+ await expect(dialog).toBeHidden()
+ expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden")
+})
diff --git a/e2e/landing-responsive.spec.ts b/e2e/landing-responsive.spec.ts
new file mode 100644
index 00000000..f5958bfb
--- /dev/null
+++ b/e2e/landing-responsive.spec.ts
@@ -0,0 +1,66 @@
+import { expect, test } from "@playwright/test"
+import type { Page } from "@playwright/test"
+
+// GF3-002 acceptance: the landing page is responsive at 390px / 768px /
+// 1440px. design-system-visual.spec.ts already covers 390 (mobile) and
+// 1280 (desktop) for every route; this file adds the two widths that
+// acceptance names but that suite doesn't have — 768 (tablet) and 1440 —
+// scoped to `/` only, so the rest of the app's baseline set is untouched.
+//
+// 768 matters most: it is the one band where the hero feature grid is at
+// `sm:grid-cols-2` while the row-span/col-span rules that shape the
+// desktop layout (`lg:`) have not applied yet.
+
+const WIDTHS = {
+ tablet: { width: 768, height: 1024 },
+ wide: { width: 1440, height: 900 },
+} as const
+
+async function stubExternalNetwork(page: Page) {
+ await page.route("**/api.binance.com/**", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
+ )
+ await page.route("**/oracle.biscotti-proxy-worker.workers.dev/**", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
+ )
+ await page.routeWebSocket("wss://stream.binance.com:9443/**", (ws) => {
+ ws.close()
+ })
+}
+
+for (const [name, viewport] of Object.entries(WIDTHS)) {
+ test.describe(`landing, ${name}`, () => {
+ test.use({ viewport, reducedMotion: "reduce" })
+
+ test.beforeEach(async ({ page }) => {
+ await stubExternalNetwork(page)
+ // Pauses the hero's word-rotation interval so it can't advance
+ // mid-screenshot — see the note in design-system-visual.spec.ts.
+ await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") })
+ await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"))
+ })
+
+ test("renders", async ({ page }) => {
+ await page.goto("/")
+ await page.waitForLoadState("networkidle")
+
+ await expect(page).toHaveScreenshot(`landing-${name}.png`, {
+ fullPage: true,
+ animations: "disabled",
+ })
+ })
+
+ test("does not scroll horizontally", async ({ page }) => {
+ await page.goto("/")
+ await page.waitForLoadState("networkidle")
+
+ // A landing section overflowing its viewport width is the classic
+ // responsive regression; assert it directly rather than relying on
+ // a reviewer spotting it in a full-page screenshot.
+ const overflows = await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ )
+ expect(overflows).toBe(false)
+ })
+ })
+}
diff --git a/e2e/landing-responsive.spec.ts-snapshots/landing-tablet-chromium-linux.png b/e2e/landing-responsive.spec.ts-snapshots/landing-tablet-chromium-linux.png
new file mode 100644
index 00000000..ad9d3cf7
Binary files /dev/null and b/e2e/landing-responsive.spec.ts-snapshots/landing-tablet-chromium-linux.png differ
diff --git a/e2e/landing-responsive.spec.ts-snapshots/landing-wide-chromium-linux.png b/e2e/landing-responsive.spec.ts-snapshots/landing-wide-chromium-linux.png
new file mode 100644
index 00000000..8255aeff
Binary files /dev/null and b/e2e/landing-responsive.spec.ts-snapshots/landing-wide-chromium-linux.png differ
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index 13f12c95..83b7ae8b 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -140,6 +140,22 @@
--text-26: 1.625rem; /* 26px */
--text-40: 2.5rem; /* 40px */
+ /*
+ * Landing-scoped additions (GF3-002) — sizes the GMX spec calls for
+ * that the app-wide scale above didn't need yet (nav links, stat
+ * numbers, section sub-heads, footer text). Namespaced by literal
+ * pixel value like their siblings above, not semantic names, for the
+ * same reason: these are a documented scale, not per-callsite magic
+ * numbers, but they don't map to an existing semantic role.
+ */
+ --text-12: 0.75rem; /* 12px — eyebrow/footer text */
+ --text-14: 0.875rem; /* 14px — nav links, body copy */
+ --text-16: 1rem; /* 16px — base body */
+ --text-18: 1.125rem; /* 18px — light-band body */
+ --text-24: 1.5rem; /* 24px — pool card name, card titles */
+ --text-28: 1.75rem; /* 28px — section sub-heads (sm) */
+ --text-30: 1.875rem; /* 30px — stat numbers */
+
/*
* Spacing scale — micro through section spacing.
*
@@ -209,6 +225,10 @@
--color-gmx-stroke-primary: oklch(0.3591 0.0539 277.68); /* #363A59 */
--color-gmx-surface-primary: oklch(0.1959 0.0264 276.95); /* #121421 */
+ /* Inline hexes used by specific landing sections (001_theme_update.md §3) */
+ --color-gmx-sponsors-border: oklch(0.8933 0.0194 276.32); /* #D8DBE9 — SponsorsSection top border */
+ --color-gmx-card-shadow: oklch(0 0 0); /* #000 — ProgramCards drop shadow */
+
--font-landing-sans: "Archivo Variable", "Geist Variable", sans-serif;
--font-landing-code: "Space Mono", "Geist Mono Variable", monospace;
@@ -224,6 +244,28 @@
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
+
+ /*
+ * Landing-scoped radius scale. SO4's app-wide --radius is 0 (sharp
+ * corners) by design (see DESIGN.md) — GMX's landing look explicitly
+ * needs a rounded scale, so these are namespaced (rounded-8/12/20)
+ * rather than touching the app's --radius-sm/md/lg/xl tokens.
+ */
+ --radius-8: 0.5rem; /* 8px — buttons, inputs, launch buttons */
+ --radius-12: 0.75rem; /* 12px — sponsor logo cards */
+ --radius-20: 1.25rem; /* 20px — feature/pool/program cards */
+
+ /* Hero title word-rotation (AnimatedTitle) */
+ --animate-title-in: title-in 0.25s ease-in-out forwards;
+ --animate-title-out: title-out 0.25s ease-in-out forwards;
+ @keyframes title-in {
+ 0% { transform: translateY(98%); opacity: 0; }
+ 100% { transform: translateY(0%); opacity: 1; }
+ }
+ @keyframes title-out {
+ 0% { transform: translateY(0%); opacity: 1; }
+ 100% { transform: translateY(-98%); opacity: 0; }
+ }
}
/*
@@ -312,7 +354,6 @@
display: none;
}
}
-
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);