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: ( + + ), + }, + { + id: "get-started", + question: "How do I get started on so4?", + answer: ( +
    +
  1. TODO(GF3-003): step one
  2. +
  3. TODO(GF3-003): step two
  4. +
  5. TODO(GF3-003): step three
  6. +
+ ), + }, + { + 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 */} + +
+ + {/* 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. -

- -
- - -
-
-
- ) -} 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 +// - -
- - ) -} - -/* ─── 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. -

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

-
- -
- - {/* 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 */} -
-
- - {icon} - -
-
{sym}
-
{name}
-
-
-
-
{price}
-
- {change} -
-
-
- - {/* Desktop layout */} -
-
- - {icon} - -
-
{sym}
-
{name}
-
-
- - - {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 ( - - ) -} 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 ( +
e.preventDefault()} + > + + +
+ ) +} 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 */} + + ) +} 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 */} +
+ ) +} 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. +

+ +
+
+ {SOCIAL_STATS.map(({ name, value, href }) => ( + +
+ {name} +
+
{value}
+
+ ))} +
+ + +
+ +
+ {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 ( +
+
+
+

{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);