Hoodquidity: liquidity campaigns for Robinhood Chain stock token markets

Full build: Vite/React acid-terminal frontend (BEARER design system,
Panchang/Sentient/Fragment Mono, neon layer, palette audit script),
HoodquidityVaults Foundry contracts (single+dual deposits via Chainlink-style
feeds, hybrid exit, 10% platform fee, boost, Genesis LP), Reown AppKit wallet
connect with Robinhood Chain testnet (46630), live data from Dexscreener and
onchain reads, partner portal, design-sync setup for claude.ai/design.

Deploy: static via Forgejo Actions platform, BUILD_DIR=apps/web/dist (.env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Денис Зефиров 2026-07-11 08:31:01 +04:00
commit dba2811447
1194 changed files with 206422 additions and 0 deletions

17
.claude/launch.json Normal file
View file

@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@hoodquidity/web", "dev"],
"autoPort": true
},
{
"name": "ds-review",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "python3 -m http.server ${PORT:-4400} -d ds-bundle"],
"autoPort": true
}
]
}

30
.design-sync/NOTES.md Normal file
View file

@ -0,0 +1,30 @@
# design-sync notes — Hoodquidity
- App-shaped repo (no library dist): components are pinned explicitly via
`componentSrcMap` (17 pins), and `entry` points at `apps/web/src/ds-entry.ts`,
a hand-written barrel exporting only the pure presentational surface.
Context-bound pieces (Header/Footer/MobileNav/AppShell — react-router;
TickerTape/DepositPanel/PositionPanel — wagmi/react-query; CampaignCard —
router Link; Preloader — sessionStorage lifecycle) are deliberately excluded.
- CSS comes from the Vite app build: `buildCmd` copies the hashed
`dist/assets/index-*.css` to the stable `dist/ds-styles.css` (cfg.cssEntry).
Rebuild the app before re-syncing if styles changed.
- Known render warns:
- `[TOKENS_MISSING] --rk-*` (43 vars) — RainbowKit runtime theme variables
baked into the compiled app CSS; injected at runtime by RainbowKitProvider
in the real app. None of the 17 synced components reference them. Safe.
- `[RENDER_SKIPPED]` — user declined the playwright/chromium install
(2026-07-09); previews are verified by eye via .review.html instead.
- Fonts: 9 woff2 files under `apps/web/public/fonts/`, listed in
`extraFonts`; @font-face rules ship inside the compiled CSS and their urls
get rewritten to `fonts/` by the converter.
## Re-sync risks
- `dist/ds-styles.css` is a snapshot of the app build — a re-sync without
re-running `buildCmd` ships stale styles for new/changed Tailwind utilities.
- Previews under `.design-sync/previews/` compose components with realistic
props; if component APIs change (e.g. Stat.value type), previews may
silently degrade — re-grade after API changes.
- No machine render check on this machine (no chromium): a future sync should
re-offer the playwright install; visual verification currently manual.

46
.design-sync/config.json Normal file
View file

@ -0,0 +1,46 @@
{
"projectId": "179c476c-1bba-4a20-93b6-6ba97067b384",
"shape": "package",
"readmeHeader": ".design-sync/conventions.md",
"pkg": "@hoodquidity/web",
"globalName": "Hoodquidity",
"buildCmd": "pnpm --filter @hoodquidity/web build && cp apps/web/dist/assets/index-*.css apps/web/dist/ds-styles.css",
"entry": "apps/web/src/ds-entry.ts",
"srcDir": "src",
"tsconfig": "tsconfig.json",
"cssEntry": "dist/ds-styles.css",
"componentSrcMap": {
"Button": "src/components/ui/button.tsx",
"Card": "src/components/ui/card.tsx",
"Badge": "src/components/ui/badge.tsx",
"Skeleton": "src/components/ui/skeleton.tsx",
"Stat": "src/components/ui/stat.tsx",
"LogoMark": "src/components/brand/Logo.tsx",
"Wordmark": "src/components/brand/Logo.tsx",
"XIcon": "src/components/icons/XIcon.tsx",
"DepthProgress": "src/components/DepthProgress.tsx",
"HealthRadial": "src/components/HealthRadial.tsx",
"SlippageCurve": "src/components/SlippageCurve.tsx",
"CountUp": "src/components/CountUp.tsx",
"GlyphMarquee": "src/components/GlyphMarquee.tsx",
"DepthField": "src/components/backdrop/DepthField.tsx",
"DepthLadder": "src/components/backdrop/DepthLadder.tsx",
"GlowField": "src/components/backdrop/GlowField.tsx",
"GlyphField": "src/components/backdrop/GlyphField.tsx"
},
"overrides": {
"GlyphMarquee": { "cardMode": "column" },
"SlippageCurve": { "cardMode": "column" }
},
"extraFonts": [
"public/fonts/Tanker-Regular.woff2",
"public/fonts/CabinetGrotesk-Extrabold.woff2",
"public/fonts/CabinetGrotesk-Bold.woff2",
"public/fonts/CabinetGrotesk-Medium.woff2",
"public/fonts/Switzer-Regular.woff2",
"public/fonts/Switzer-Medium.woff2",
"public/fonts/Switzer-Semibold.woff2",
"public/fonts/JetBrainsMono-Regular.woff2",
"public/fonts/JetBrainsMono-SemiBold.woff2"
]
}

View file

@ -0,0 +1,62 @@
# Hoodquidity conventions
Dark-first acid-terminal DeFi aesthetic: near-black backgrounds, acid lime
`#c8ff00` as the only strong accent, monospace numerals, uppercase display
type. Components come from `window.Hoodquidity.*`. No provider or wrapper is
required — every component renders standalone.
## Styling idiom
Tailwind utility classes over design tokens. CRITICAL: the shipped stylesheet
is compiled — it contains ONLY the classes listed below plus what the
components themselves use. Do not invent other Tailwind classes (they will not
resolve); for anything beyond this vocabulary use inline styles with the
`var(--color-*)` tokens.
Color utilities (bg-/text-/border- work for each token):
- Surfaces: `bg-void` (page), `bg-surface`, `bg-card`, `bg-card-hover`
- Accent: `text-mint` / `bg-mint` (acid #c8ff00), `text-lime` (pale #eaff8a), `bg-mint-soft` (10% tint)
- Text: `text-snow` (off-white), `text-fog` (muted), `text-fog-dim` (faint)
- Lines: `border-edge`, `border-edge-bright`
- Status: `text-amber`, `text-danger`
Typography: `font-display` (Tanker — uppercase headlines; h1/h2 get it
automatically), `font-mono` (JetBrains Mono — all data/numbers), body text is
Switzer by default. Signature helper classes shipped in the CSS:
- `num` — tabular-nums mono, use on every numeric value
- `slabel` — section label, renders a `// LABEL` terminal prefix
- `leader` — dotted-leader row (label ..... value), first child label, last child value
- `ticks` — terminal corner ticks on cards (Card applies it already)
- `grain` — film-grain overlay for a page root; `dither` — Bayer dither texture
- `sidelabel` — vertical rotated display text; `btn-sweep` — hover light sweep
- `animate-marquee`, `animate-pulse-dot`, `hover:shadow-glow`
## Where the truth lives
Read `styles.css` and its imports (tokens in `:root` as `--color-*`,
`--font-*`; fonts ship in `fonts/`) before styling. Per-component API and
usage: `components/<group>/<Name>/<Name>.d.ts` and `.prompt.md`.
## Idiomatic snippet
```jsx
const { Card, Badge, Stat, Button, DepthProgress } = window.Hoodquidity;
<div className="bg-void min-h-screen p-8">
<Card className="space-y-4">
<div className="flex items-center justify-between">
<span className="font-display text-xl">NVDA <span className="text-fog-dim">/ hUSD</span></span>
<Badge tone="mint" pulse>Active</Badge>
</div>
<DepthProgress progress={0.55} />
<div className="leader text-sm">
<span className="text-fog">Vault TVL</span>
<span className="num font-semibold text-mint">$215K</span>
</div>
<Button size="lg" className="w-full">Deposit hUSD</Button>
</Card>
</div>
```
Content voice: headings without trailing periods, no em dashes, realistic
fintech data (tickers NVDA/TSLA/SPY, USD amounts, APR percentages).

View file

@ -0,0 +1,31 @@
import { Badge } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Tones = () => (
<Panel>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<Badge tone="mint">Active</Badge>
<Badge tone="lime">Boost window</Badge>
<Badge tone="amber">Medium risk</Badge>
<Badge tone="danger">High risk</Badge>
<Badge tone="fog">Ended</Badge>
</div>
</Panel>
);
export const LiveIndicators = () => (
<Panel>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<Badge tone="mint" pulse>
Live on Robinhood Chain testnet
</Badge>
<Badge tone="lime" pulse>
Boost window open
</Badge>
</div>
</Panel>
);

View file

@ -0,0 +1,45 @@
import { Button } from "@hoodquidity/web";
import type { ReactNode } from "react";
/** Cards render on white; the DS is dark — stories sit on the app's void bg. */
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Primary = () => (
<Panel>
<Button>Explore campaigns</Button>
</Panel>
);
export const Variants = () => (
<Panel>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<Button variant="primary">Deposit hUSD</Button>
<Button variant="outline">View market depth</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="danger">Exit early</Button>
</div>
</Panel>
);
export const Sizes = () => (
<Panel>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Button size="sm">Claim</Button>
<Button size="md">Deposit</Button>
<Button size="lg">Start providing depth</Button>
</div>
</Panel>
);
export const Disabled = () => (
<Panel>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Button disabled>Confirming...</Button>
<Button variant="outline" disabled>
Campaign ended
</Button>
</div>
</Panel>
);

View file

@ -0,0 +1,36 @@
import { Card, Badge, Stat } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Basic = () => (
<Panel>
<Card>
<div style={{ fontFamily: "Cabinet Grotesk", fontSize: 20, fontWeight: 700 }}>
NVDA <span style={{ opacity: 0.4 }}>/ hUSD</span>
</div>
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#94a26b", lineHeight: 1.5 }}>
Depth campaign for the NVIDIA Stock Token market. Deposits flow into the
Depth Vault and count toward the campaign target.
</p>
</Card>
</Panel>
);
export const WithStats = () => (
<Panel>
<Card>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<span style={{ fontFamily: "Cabinet Grotesk", fontSize: 18, fontWeight: 700 }}>Campaign vault</span>
<Badge tone="mint" pulse>Active</Badge>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 24 }}>
<Stat label="Vault TVL" value="$215K" accent />
<Stat label="Est APR" value="66.3%" />
<Stat label="Time left" value="13d 4h" />
</div>
</Card>
</Panel>
);

View file

@ -0,0 +1,30 @@
import { CountUp } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
const usd = (n: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
export const Currency = () => (
<Panel>
<span style={{ fontFamily: "JetBrains Mono", fontSize: 28, fontWeight: 600, color: "#c8ff00" }}>
<CountUp value={10_500_000} format={usd} />
</span>
</Panel>
);
export const Plain = () => (
<Panel>
<span style={{ fontFamily: "JetBrains Mono", fontSize: 28, fontWeight: 600, color: "#f4ffd8" }}>
<CountUp value={1284} format={(n) => Math.round(n).toLocaleString("en-US")} />
</span>
</Panel>
);

View file

@ -0,0 +1,13 @@
import { DepthField } from "@hoodquidity/web";
export const Default = () => (
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<DepthField />
</div>
);
export const Flipped = () => (
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<DepthField flip />
</div>
);

View file

@ -0,0 +1,7 @@
import { DepthLadder } from "@hoodquidity/web";
export const Default = () => (
<div style={{ width: 420, height: 300, background: "#060704", borderRadius: 10, padding: 12 }}>
<DepthLadder />
</div>
);

View file

@ -0,0 +1,39 @@
import { DepthProgress } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
const Row = ({ label, children }: { label: string; children: ReactNode }) => (
<div style={{ display: "grid", gap: 6, width: 360 }}>
<span style={{ fontFamily: "JetBrains Mono", fontSize: 10, letterSpacing: "0.18em", textTransform: "uppercase", color: "#5d6742" }}>
{label}
</span>
{children}
</div>
);
export const Levels = () => (
<Panel>
<div style={{ display: "grid", gap: 18 }}>
<Row label="Collecting - 18%">
<DepthProgress progress={0.18} />
</Row>
<Row label="Halfway - 55%">
<DepthProgress progress={0.55} />
</Row>
<Row label="Target reached - 100%">
<DepthProgress progress={1} />
</Row>
</div>
</Panel>
);
export const FineGrained = () => (
<Panel>
<Row label="48 segments - 72%">
<DepthProgress progress={0.72} segments={48} />
</Row>
</Panel>
);

View file

@ -0,0 +1,7 @@
import { GlowField } from "@hoodquidity/web";
export const Default = () => (
<div style={{ position: "relative", height: 280, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<GlowField />
</div>
);

View file

@ -0,0 +1,13 @@
import { GlyphField } from "@hoodquidity/web";
export const Default = () => (
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<GlyphField opacity={0.7} />
</div>
);
export const Subtle = () => (
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<GlyphField opacity={0.3} />
</div>
);

View file

@ -0,0 +1,7 @@
import { GlyphMarquee } from "@hoodquidity/web";
export const Default = () => (
<div style={{ background: "#060704", borderRadius: 10, overflow: "hidden" }}>
<GlyphMarquee />
</div>
);

View file

@ -0,0 +1,16 @@
import { HealthRadial } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Scores = () => (
<Panel>
<div style={{ display: "flex", gap: 40, flexWrap: "wrap" }}>
<HealthRadial score={82} />
<HealthRadial score={55} />
<HealthRadial score={18} />
</div>
</Panel>
);

View file

@ -0,0 +1,17 @@
import { LogoMark } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Sizes = () => (
<Panel>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<LogoMark className="h-6 w-6" />
<LogoMark className="h-8 w-8" />
<LogoMark className="h-12 w-12" />
<LogoMark className="h-16 w-16" />
</div>
</Panel>
);

View file

@ -0,0 +1,31 @@
import { Skeleton, Card } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const TextLines = () => (
<Panel>
<div style={{ display: "grid", gap: 10, maxWidth: 360 }}>
<Skeleton style={{ height: 24, width: "60%" }} />
<Skeleton style={{ height: 14, width: "100%" }} />
<Skeleton style={{ height: 14, width: "85%" }} />
</div>
</Panel>
);
export const LoadingCard = () => (
<Panel>
<Card style={{ maxWidth: 380 }}>
<div style={{ display: "grid", gap: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Skeleton style={{ height: 22, width: 130 }} />
<Skeleton style={{ height: 20, width: 70, borderRadius: 999 }} />
</div>
<Skeleton style={{ height: 26, width: 110 }} />
<Skeleton style={{ height: 10, width: "100%" }} />
</div>
</Card>
</Panel>
);

View file

@ -0,0 +1,18 @@
import { SlippageCurve } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10, maxWidth: 460 }}>{children}</div>
);
export const DeepMarket = () => (
<Panel>
<SlippageCurve liquidityUsd={5_400_000} />
</Panel>
);
export const ShallowMarket = () => (
<Panel>
<SlippageCurve liquidityUsd={420_000} />
</Panel>
);

View file

@ -0,0 +1,29 @@
import { Stat } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Single = () => (
<Panel>
<Stat label="Total value locked" value="$4.2M" accent />
</Panel>
);
export const WithHint = () => (
<Panel>
<Stat label="Token price" value="$201.85" hint="+2.32% 24h" />
</Panel>
);
export const StatRow = () => (
<Panel>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 32 }}>
<Stat label="Tracked market depth" value="$10.5M" accent />
<Stat label="Active campaigns" value="5" />
<Stat label="24h market volume" value="$5.4M" />
<Stat label="Vault TVL" value="$215K" />
</div>
</Panel>
);

View file

@ -0,0 +1,21 @@
import { LogoMark, Wordmark } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Default = () => (
<Panel>
<Wordmark />
</Panel>
);
export const Lockup = () => (
<Panel>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<LogoMark />
<Wordmark />
</div>
</Panel>
);

View file

@ -0,0 +1,18 @@
import { XIcon } from "@hoodquidity/web";
import type { ReactNode } from "react";
const Panel = ({ children }: { children: ReactNode }) => (
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
);
export const Sizes = () => (
<Panel>
<div style={{ display: "flex", gap: 20, alignItems: "center", color: "#f4ffd8" }}>
<XIcon className="h-4 w-4" />
<XIcon className="h-6 w-6" />
<span style={{ color: "#c8ff00" }}>
<XIcon className="h-8 w-8" />
</span>
</div>
</Panel>
);

3
.env Normal file
View file

@ -0,0 +1,3 @@
DOMAIN=hoodquidity.xyz
DEPLOY=static
BUILD_DIR=apps/web/dist

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Deploy config (Forgejo Actions platform). Copy to .env and commit.
# The site domain, A-record must point at the platform server
DOMAIN=hoodquidity.xyz
# Static frontend built from the pnpm/bun workspace
DEPLOY=static
BUILD_DIR=apps/web/dist

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules/
dist/
out/
cache/
broadcast/
# .env is deploy config and is committed (platform reads it); secrets never go there
.env.local
.DS_Store
*.log
.ds-sync/
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules

56
README.md Normal file
View file

@ -0,0 +1,56 @@
# Hoodquidity
Liquidity campaigns for Stock Token markets on Robinhood Chain. Deposit
stablecoins into a Depth Vault, receive an LP receipt and earn from deeper
markets.
## Stack
- `apps/web`: React 18 + Vite + Tailwind v4, wagmi + viem + RainbowKit,
Framer Motion, SVG animations. Ships as a static build.
- `packages/contracts`: Foundry. `HoodquidityVaults` (campaigns, deposits,
masterchef-style rewards with early boost, hybrid exit) and `MockERC20`
(test assets with an open faucet).
Live data: Dexscreener API for tokenized stock prices, volume and liquidity;
`https://1rpc.io/eth` for L1 chain stats; the vault contract for TVL,
positions and rewards.
## Development
```sh
pnpm install
# terminal 1: local chain
anvil --port 8545
# terminal 2: multicall3 + demo deployment (5 campaigns)
./packages/contracts/script/setup-local.sh
# terminal 3: the app
pnpm dev
```
If deploy addresses change, update `apps/web/src/lib/contracts.ts`.
## Tests
```sh
pnpm test:contracts
```
## Production build
```sh
pnpm build # apps/web/dist, precompressed with gzip + brotli
pnpm preview
```
Deploy only the `apps/web/dist` directory to the server.
## Before release
- Replace the WalletConnect `projectId` in `apps/web/src/lib/wagmi.ts`
- Set the real Twitter URL in `apps/web/src/lib/constants.ts`
- Deploy contracts to the public testnet (`script/Deploy.s.sol`) and fill in
the `testnet` deployment in `apps/web/src/lib/contracts.ts`

16
apps/web/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="base-verification" content="7f3a9c21-4e8b-4d52-a1c6-9b0e2f8d4471" />
<meta name="theme-color" content="#080808" />
<meta name="description" content="Provide liquidity to Stock Token markets and earn from deeper markets" />
<title>Hoodquidity</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
apps/web/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@hoodquidity/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"audit:palette": "bash scripts/palette-audit.sh"
},
"dependencies": {
"@reown/appkit": "^1.8.22",
"@reown/appkit-adapter-wagmi": "^1.8.22",
"@tanstack/react-query": "^5.59.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"motion": "^11.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.0",
"viem": "^2.21.0",
"wagmi": "^2.12.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-compression2": "^2.5.3"
}
}

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Palette audit: fails when a color literal outside the BEARER palette appears
# in the app source. Alpha (rgba) variants of palette tokens are allowed.
# SVG luminance masks (#fff/#000 in GlyphField) are exempt by design.
set -uo pipefail
cd "$(dirname "$0")/.."
# hex tokens, lowercase
ALLOWED_HEX="080808|111111|161616|1c1c1c|2a2a2a|3d3d3d|f2f2f2|919191|5c5c5c|c8ff00|8fb000|e9ffb3|5200ff|8a5cff|ff3b00"
# rgba bases: mint 200,255,0 · volt 82,0,255 · lime 233,255,179 · snow 242,242,242
ALLOWED_RGBA="^rgba?\((200, ?255, ?0|82, ?0, ?255|233, ?255, ?179|242, ?242, ?242)(, ?[0-9.]+)?\)$"
FAIL=0
# --- hex literals -----------------------------------------------------------
while IFS=: read -r file line hex; do
low=$(printf "%s" "$hex" | tr 'A-F' 'a-f')
norm=${low#\#}
[[ "$norm" =~ ^($ALLOWED_HEX)$ ]] && continue
# exempt: luminance mask stops in GlyphField
if [[ "$file" == *"GlyphField"* && ( "$norm" == "fff" || "$norm" == "000" ) ]]; then
continue
fi
echo "STRAY HEX $file:$line $hex"
FAIL=1
done < <(grep -rnoE "#[0-9a-fA-F]{3,8}\b" src/ index.html | grep -v "^\s*//" )
# --- rgb/rgba literals ------------------------------------------------------
while IFS=: read -r file line rgba; do
[[ "$rgba" =~ $ALLOWED_RGBA ]] && continue
echo "STRAY RGBA $file:$line $rgba"
FAIL=1
done < <(grep -rnoE "rgba?\([0-9, .]+\)" src/)
if [[ $FAIL -eq 0 ]]; then
echo "palette audit clean: every literal maps to a BEARER token"
fi
exit $FAIL

25
apps/web/src/App.tsx Normal file
View file

@ -0,0 +1,25 @@
import { Routes, Route } from "react-router-dom";
import { AppShell } from "@/components/layout/AppShell";
import Landing from "@/screens/Landing";
import AppBoard from "@/screens/AppBoard";
import Campaigns from "@/screens/Campaigns";
import CampaignDetail from "@/screens/CampaignDetail";
import Markets from "@/screens/Markets";
import Positions from "@/screens/Positions";
import Partners from "@/screens/Partners";
export default function App() {
return (
<AppShell>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/app" element={<AppBoard />} />
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/campaigns/:id" element={<CampaignDetail />} />
<Route path="/markets" element={<Markets />} />
<Route path="/positions" element={<Positions />} />
<Route path="/partners" element={<Partners />} />
</Routes>
</AppShell>
);
}

View file

@ -0,0 +1,149 @@
import { Link } from "react-router-dom";
import { motion } from "motion/react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { DepthProgress } from "@/components/DepthProgress";
import type { CampaignState } from "@/hooks/useCampaigns";
import type { PairData } from "@/lib/dexscreener";
import { feeApr, rewardApr } from "@/lib/math";
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
import { inView } from "@/lib/motion";
import { cn } from "@/lib/utils";
const STATUS_META = {
boost: { label: "Boost window", tone: "lime" as const, pulse: true },
active: { label: "Active", tone: "mint" as const, pulse: true },
upcoming: { label: "Upcoming", tone: "fog" as const, pulse: false },
ended: { label: "Ended", tone: "fog" as const, pulse: false },
};
const RISK_TONE = { Low: "mint", Medium: "fog", High: "danger" } as const;
export function CampaignCard({
campaign,
pair,
index,
}: {
campaign: CampaignState;
pair: PairData | null | undefined;
index: number;
}) {
const { meta } = campaign;
const marketDepth = (pair?.liquidityUsd ?? 0) + campaign.tvlUsd;
const progress = campaign.targetDepthUsd > 0 ? marketDepth / campaign.targetDepthUsd : 0;
const fApr = pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0;
const rApr = rewardApr(campaign.rewardBudgetUsd, campaign.durationDays, campaign.tvlUsd);
const status = STATUS_META[campaign.status];
return (
<motion.div
{...inView}
whileHover={{ y: -5 }}
transition={{ duration: 0.45, delay: index * 0.07 }}
>
<Link to={`/campaigns/${meta.campaignId}`} className="block">
<Card className="group h-full space-y-4 hover:border-edge-bright hover:bg-card-hover">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-display text-xl font-bold">
{meta.symbol}
<span className="font-mono text-[0.6em] font-normal normal-case tracking-normal text-fog-dim"> / hUSD</span>
</div>
<div className="mt-0.5 text-xs text-fog">{meta.name}</div>
</div>
<Badge tone={status.tone} pulse={status.pulse}>
{status.label}
</Badge>
</div>
<div className="flex items-baseline justify-between">
{pair ? (
<>
<span className="num text-lg font-semibold">
${pair.priceUsd.toFixed(2)}
</span>
<span
className={cn(
"num text-xs",
pair.change24h >= 0 ? "text-mint" : "text-danger",
)}
>
{pair.change24h >= 0 ? "+" : ""}
{pair.change24h.toFixed(2)}% 24h
</span>
</>
) : (
<Skeleton className="h-6 w-28" />
)}
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="font-mono uppercase tracking-wider text-fog-dim">
Depth
</span>
<span className="num text-fog">
{fmtUsd(marketDepth)} / {fmtUsd(campaign.targetDepthUsd)}
</span>
</div>
<DepthProgress progress={progress} />
</div>
<div className="grid grid-cols-3 gap-2 border-t border-edge pt-3 text-center">
<div>
<div className="num text-sm font-semibold text-mint">
{fmtAprPct(fApr + rApr)}
</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
Est APR
</div>
</div>
<div>
<div className="num text-sm font-semibold">{fmtUsd(campaign.tvlUsd)}</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
Vault TVL
</div>
</div>
<div>
<div className="num text-sm font-semibold">
{campaign.status === "ended" ? "Closed" : fmtCountdown(campaign.end)}
</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
{campaign.status === "upcoming" ? "Starts in" : "Time left"}
</div>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<Badge tone={RISK_TONE[meta.risk]}>{meta.risk} risk</Badge>
<span className="font-mono text-fog-dim transition-colors group-hover:text-mint">
Enter vault
</span>
</div>
</Card>
</Link>
</motion.div>
);
}
export function CampaignCardSkeleton() {
return (
<Card className="h-full space-y-4">
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-6 w-28" />
<Skeleton className="h-2.5 w-full" />
<div className="grid grid-cols-3 gap-2 pt-3">
<Skeleton className="h-10" />
<Skeleton className="h-10" />
<Skeleton className="h-10" />
</div>
</Card>
);
}

View file

@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from "react";
import { motionEnabled } from "@/lib/motion";
/** Eases a number toward its live value, so stats tick up instead of popping. */
export function CountUp({
value,
format,
duration = 1100,
}: {
value: number;
format: (n: number) => string;
duration?: number;
}) {
const [display, setDisplay] = useState(() => (motionEnabled ? 0 : value));
const displayRef = useRef(display);
displayRef.current = display;
useEffect(() => {
if (!motionEnabled) {
setDisplay(value);
return;
}
const from = displayRef.current;
if (from === value) return;
let raf = 0;
const start = performance.now();
const tick = (t: number) => {
const p = Math.min((t - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setDisplay(from + (value - from) * eased);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [value, duration]);
return <>{format(display)}</>;
}

View file

@ -0,0 +1,321 @@
import { useMemo, useState } from "react";
import { parseUnits } from "viem";
import {
useAccount,
useReadContracts,
useWaitForTransactionReceipt,
useWriteContract,
} from "wagmi";
import { useAppKit } from "@reown/appkit/react";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import { mockERC20Abi } from "@/lib/abi/MockERC20";
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
import { slippageImpact } from "@/lib/math";
import { fmtPct, fmtUsd } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CampaignState } from "@/hooks/useCampaigns";
import type { PairData } from "@/lib/dexscreener";
const PREVIEW_TRADE = 5_000;
const ZERO = "0x0000000000000000000000000000000000000000" as const;
type Mode = "single" | "dual";
export function DepositPanel({
campaign,
pair,
onSettled,
}: {
campaign: CampaignState;
pair: PairData | null | undefined;
onSettled: () => void;
}) {
const { address, isConnected } = useAccount();
const { open: openConnectModal } = useAppKit();
const deployment = getActiveDeployment();
const [mode, setMode] = useState<Mode>("single");
const [input, setInput] = useState("");
const [stockInput, setStockInput] = useState("");
const stockToken = campaign.meta.stockToken;
const symbol = campaign.meta.symbol;
const amount = useMemo(() => {
const n = Number(input);
if (!Number.isFinite(n) || n < 0) return 0n;
try {
return input ? parseUnits(input, USD_DECIMALS) : 0n;
} catch {
return 0n;
}
}, [input]);
const stockAmount = useMemo(() => {
const n = Number(stockInput);
if (!Number.isFinite(n) || n < 0) return 0n;
try {
return stockInput ? parseUnits(stockInput, 18) : 0n;
} catch {
return 0n;
}
}, [stockInput]);
const { data: walletData, refetch: refetchWallet } = useReadContracts({
contracts: [
{
address: deployment.usd,
abi: mockERC20Abi,
functionName: "balanceOf",
args: [address ?? ZERO],
chainId: ACTIVE_CHAIN_ID,
},
{
address: deployment.usd,
abi: mockERC20Abi,
functionName: "allowance",
args: [address ?? ZERO, deployment.vaults],
chainId: ACTIVE_CHAIN_ID,
},
{
address: stockToken,
abi: mockERC20Abi,
functionName: "balanceOf",
args: [address ?? ZERO],
chainId: ACTIVE_CHAIN_ID,
},
{
address: stockToken,
abi: mockERC20Abi,
functionName: "allowance",
args: [address ?? ZERO, deployment.vaults],
chainId: ACTIVE_CHAIN_ID,
},
{
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "quoteStock",
args: [BigInt(campaign.meta.campaignId), stockAmount > 0n ? stockAmount : 10n ** 18n],
chainId: ACTIVE_CHAIN_ID,
},
],
query: { enabled: !!address },
});
const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n;
const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n;
const stockBalance = (walletData?.[2]?.result as bigint | undefined) ?? 0n;
const stockAllowance = (walletData?.[3]?.result as bigint | undefined) ?? 0n;
const stockQuote = (walletData?.[4]?.result as bigint | undefined) ?? 0n;
/** USD value of the entered stock leg (or unit price when input empty). */
const stockValueUsd = Number(stockQuote) / 10 ** USD_DECIMALS;
const { writeContract, data: txHash, isPending, reset } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
if (isSuccess) {
reset();
refetchWallet();
onSettled();
}
const busy = isPending || isConfirming;
const dual = mode === "dual";
const needsUsdFaucet = amount > 0n && balance < amount;
const needsUsdApprove = !needsUsdFaucet && amount > 0n && allowance < amount;
const needsStockFaucet = dual && stockAmount > 0n && stockBalance < stockAmount;
const needsStockApprove =
dual && !needsStockFaucet && stockAmount > 0n && stockAllowance < stockAmount;
const ready = dual ? stockAmount > 0n : amount > 0n;
const depositUsd = (Number(input) || 0) + (dual && stockAmount > 0n ? stockValueUsd : 0);
const impact = pair
? slippageImpact(PREVIEW_TRADE, pair.liquidityUsd + campaign.tvlUsd, depositUsd)
: null;
const act = () => {
if (!isConnected) return openConnectModal?.();
if (needsUsdFaucet) {
writeContract({
address: deployment.usd,
abi: mockERC20Abi,
functionName: "faucet",
chainId: ACTIVE_CHAIN_ID,
});
} else if (needsStockFaucet) {
writeContract({
address: stockToken,
abi: mockERC20Abi,
functionName: "faucet",
chainId: ACTIVE_CHAIN_ID,
});
} else if (needsUsdApprove) {
writeContract({
address: deployment.usd,
abi: mockERC20Abi,
functionName: "approve",
args: [deployment.vaults, amount],
chainId: ACTIVE_CHAIN_ID,
});
} else if (needsStockApprove) {
writeContract({
address: stockToken,
abi: mockERC20Abi,
functionName: "approve",
args: [deployment.vaults, stockAmount],
chainId: ACTIVE_CHAIN_ID,
});
} else if (dual) {
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "depositDual",
args: [BigInt(campaign.meta.campaignId), stockAmount, amount],
chainId: ACTIVE_CHAIN_ID,
});
} else {
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "deposit",
args: [BigInt(campaign.meta.campaignId), amount],
chainId: ACTIVE_CHAIN_ID,
});
}
};
const label = !isConnected
? "Connect wallet"
: !ready
? dual
? `Enter ${symbol} amount`
: "Enter amount"
: needsUsdFaucet
? "Get test hUSD"
: needsStockFaucet
? `Get test ${symbol}`
: needsUsdApprove
? "Approve hUSD"
: needsStockApprove
? `Approve ${symbol}`
: campaign.status === "boost"
? "Deposit with 1.5x boost"
: dual
? "Deposit dual"
: "Deposit";
return (
<div className="space-y-4">
{/* mode tabs */}
<div className="grid grid-cols-2 gap-1 rounded-none border border-edge bg-surface p-1">
{(["single", "dual"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={cn(
"cursor-pointer rounded-none px-3 py-1.5 font-mono text-[11px] uppercase tracking-wider transition-colors",
mode === m ? "bg-mint text-void" : "text-fog hover:text-snow",
)}
>
{m === "single" ? "Single-sided" : "Dual-sided"}
</button>
))}
</div>
{dual && (
<div className="ticks rounded-none border border-edge bg-surface p-4">
<div className="flex items-center justify-between text-[11px] uppercase tracking-wider text-fog-dim">
<span>Stock leg</span>
<button
type="button"
className="num cursor-pointer text-fog transition-colors hover:text-mint"
onClick={() => setStockInput((Number(stockBalance) / 1e18).toString())}
>
Balance {(Number(stockBalance) / 1e18).toFixed(4)}
</button>
</div>
<div className="mt-2 flex items-center gap-3">
<input
value={stockInput}
onChange={(e) => setStockInput(e.target.value.replace(/[^0-9.]/g, ""))}
placeholder="0.0"
inputMode="decimal"
className="num w-full bg-transparent text-2xl font-semibold text-snow outline-none placeholder:text-fog-dim"
/>
<span className="font-mono text-sm font-semibold text-fog">{symbol}</span>
</div>
<div className="mt-1.5 num text-[11px] text-fog-dim">
{stockAmount > 0n
? `Feed value ${fmtUsd(stockValueUsd, false)}`
: `Feed price ${fmtUsd(stockValueUsd, false)} per ${symbol}`}
</div>
</div>
)}
<div className="ticks rounded-none border border-edge bg-surface p-4">
<div className="flex items-center justify-between text-[11px] uppercase tracking-wider text-fog-dim">
<span>{dual ? "Stable leg" : "You deposit"}</span>
<button
type="button"
className="num cursor-pointer text-fog transition-colors hover:text-mint"
onClick={() => setInput((Number(balance) / 10 ** USD_DECIMALS).toString())}
>
Balance {fmtUsd(Number(balance) / 10 ** USD_DECIMALS)}
</button>
</div>
<div className="mt-2 flex items-center gap-3">
<input
value={input}
onChange={(e) => setInput(e.target.value.replace(/[^0-9.]/g, ""))}
placeholder="0.00"
inputMode="decimal"
className="num w-full bg-transparent text-2xl font-semibold text-snow outline-none placeholder:text-fog-dim"
/>
<span className="font-mono text-sm font-semibold text-fog">hUSD</span>
</div>
</div>
{dual && stockAmount > 0n && (
<div className="num text-[11px] text-fog">
Position credit {fmtUsd(depositUsd, false)}
<span className="text-fog-dim">
{" "}
· for a balanced 50/50 add {fmtUsd(stockValueUsd, false)} hUSD
</span>
</div>
)}
{impact && depositUsd > 0 && (
<div className="rounded-none border border-mint/20 bg-mint-soft p-3.5 text-xs leading-relaxed text-snow">
Your deposit may reduce estimated slippage for a{" "}
{fmtUsd(PREVIEW_TRADE)} trade from{" "}
<span className="num font-semibold text-danger">{fmtPct(impact.before)}</span> to{" "}
<span className="num font-semibold text-mint">{fmtPct(impact.after)}</span>
</div>
)}
<Button
className="w-full"
size="lg"
disabled={busy || (isConnected && !ready) || campaign.status === "ended"}
onClick={act}
>
{busy ? "Confirming..." : label}
</Button>
<p className="text-[11px] leading-relaxed text-fog-dim">
Potential rewards only. Trading fees depend on volume, rewards are
subject to campaign terms and the LP position may lose value. Exiting
before the campaign ends forfeits campaign rewards and applies a 0.1%
exit fee. Reward payouts carry a 10% platform fee.
{dual && " The stock leg is valued by the campaign price feed at deposit time."}
</p>
</div>
);
}

View file

@ -0,0 +1,260 @@
import { useMemo, useRef, useState } from "react";
import { motion } from "motion/react";
import { fmtUsd } from "@/lib/format";
import { motionEnabled } from "@/lib/motion";
const W = 960;
const H = 380;
const PAD_X = 8;
const BASE_Y = H - 34;
const TOP_Y = 26;
const STEPS = 42;
/** Deterministic per-index jitter so the book looks organic but stable. */
function jitter(i: number, salt: number) {
const x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453;
return x - Math.floor(x);
}
function sideSteps(totalUsd: number, salt: number) {
const pts: { frac: number; cum: number }[] = [];
let cum = 0;
const weights: number[] = [];
let wSum = 0;
for (let i = 0; i < STEPS; i++) {
const w = 0.35 + jitter(i, salt) * (i / STEPS) * 2.2;
weights.push(w);
wSum += w;
}
for (let i = 0; i < STEPS; i++) {
cum += (weights[i] / wSum) * totalUsd;
pts.push({ frac: (i + 1) / STEPS, cum });
}
return pts;
}
/**
* Cumulative order-book depth, mirrored around mid. X axis is price offset
* from mid, Y is cumulative depth in USD. Crosshair follows the cursor with a
* mono readout. Pure SVG, driven by the real tracked-depth figure.
*/
export function DepthChart({ totalDepthUsd }: { totalDepthUsd: number }) {
const ref = useRef<SVGSVGElement>(null);
const [cursor, setCursor] = useState<number | null>(null);
const { bidPath, askPath, bidLine, askLine, maxCum, bids, asks } = useMemo(() => {
const total = Math.max(totalDepthUsd, 1);
const bids = sideSteps(total * 0.52, 1.37);
const asks = sideSteps(total * 0.48, 7.91);
const maxCum = Math.max(bids[STEPS - 1].cum, asks[STEPS - 1].cum);
const midX = W / 2;
const halfW = midX - PAD_X;
const yFor = (cum: number) =>
BASE_Y - (cum / maxCum) * (BASE_Y - TOP_Y);
const walk = (pts: typeof bids, dir: 1 | -1) => {
let d = `M ${midX} ${BASE_Y}`;
let prevY = BASE_Y;
for (const p of pts) {
const x = midX + dir * p.frac * halfW;
const y = yFor(p.cum);
d += ` L ${x} ${prevY} L ${x} ${y}`;
prevY = y;
}
return { area: `${d} L ${midX + dir * halfW} ${BASE_Y} Z`, line: d };
};
const b = walk(bids, -1);
const a = walk(asks, 1);
return {
bidPath: b.area,
askPath: a.area,
bidLine: b.line,
askLine: a.line,
maxCum,
bids,
asks,
};
}, [totalDepthUsd]);
const readout = useMemo(() => {
if (cursor == null) return null;
const midX = W / 2;
const halfW = midX - PAD_X;
const off = (cursor - midX) / halfW; // -1..1
const side = off < 0 ? bids : asks;
const frac = Math.min(Math.abs(off), 1);
const idx = Math.min(Math.floor(frac * STEPS), STEPS - 1);
const cum = frac < 1 / STEPS ? side[0].cum * (frac * STEPS) : side[idx].cum;
const y = BASE_Y - (cum / maxCum) * (BASE_Y - TOP_Y);
return {
x: cursor,
y,
pct: `${off >= 0 ? "+" : ""}${(Math.abs(off) * 1.2).toFixed(2)}%`,
cum: fmtUsd(cum),
side: off < 0 ? "bid" : "ask",
};
}, [cursor, bids, asks, maxCum]);
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const x = ((e.clientX - rect.left) / rect.width) * W;
setCursor(Math.max(PAD_X, Math.min(W - PAD_X, x)));
};
return (
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
className="h-full w-full cursor-crosshair select-none"
onMouseMove={onMove}
onMouseLeave={() => setCursor(null)}
role="img"
aria-label="Cumulative order book depth"
>
<defs>
<linearGradient id="dc-bid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0.32" />
<stop offset="100%" stopColor="#c8ff00" stopOpacity="0.02" />
</linearGradient>
<linearGradient id="dc-ask" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5200ff" stopOpacity="0.38" />
<stop offset="100%" stopColor="#5200ff" stopOpacity="0.03" />
</linearGradient>
{/* oscilloscope tube glow for the book lines */}
<filter id="dc-glow-bid" x="-20%" y="-60%" width="140%" height="220%">
<feDropShadow dx="0" dy="0" stdDeviation="3.2" floodColor="#c8ff00" floodOpacity="0.7" />
</filter>
<filter id="dc-glow-ask" x="-20%" y="-60%" width="140%" height="220%">
<feDropShadow dx="0" dy="0" stdDeviation="3.2" floodColor="#5200ff" floodOpacity="0.8" />
</filter>
</defs>
{/* side labels: acid means bids hold value, violet is the counter side */}
<text
x={PAD_X + 6}
y={TOP_Y + 4}
fill="#c8ff00"
fontSize="10"
fontFamily="Fragment Mono, monospace"
letterSpacing="0.16em"
>
BIDS
</text>
<text
x={W - PAD_X - 6}
y={TOP_Y + 4}
textAnchor="end"
fill="#8a5cff"
fontSize="10"
fontFamily="Fragment Mono, monospace"
letterSpacing="0.16em"
>
ASKS
</text>
{/* frame ticks */}
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const x = PAD_X + t * (W - PAD_X * 2);
return (
<g key={t}>
<line x1={x} y1={TOP_Y - 8} x2={x} y2={BASE_Y} stroke="#2a2a2a" strokeWidth="1" />
<text
x={x}
y={H - 12}
textAnchor="middle"
fill="#5c5c5c"
fontSize="10"
fontFamily="JetBrains Mono, monospace"
letterSpacing="0.08em"
>
{t === 0.5 ? "MID" : `${t < 0.5 ? "" : "+"}${(Math.abs(t - 0.5) * 2.4).toFixed(1)}%`}
</text>
</g>
);
})}
<line x1={PAD_X} y1={BASE_Y} x2={W - PAD_X} y2={BASE_Y} stroke="#3d3d3d" strokeWidth="1" />
{/* depth areas */}
<motion.g
initial={motionEnabled ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 1.1, delay: 0.5 }}
>
<path d={bidPath} fill="url(#dc-bid)" />
<path d={askPath} fill="url(#dc-ask)" />
<path
d={bidLine}
fill="none"
stroke="#c8ff00"
strokeWidth="1.5"
strokeOpacity="0.95"
filter="url(#dc-glow-bid)"
/>
<path
d={askLine}
fill="none"
stroke="#8a5cff"
strokeWidth="1.5"
strokeOpacity="0.9"
filter="url(#dc-glow-ask)"
/>
</motion.g>
{/* mid marker */}
<line
x1={W / 2}
y1={TOP_Y - 8}
x2={W / 2}
y2={BASE_Y}
stroke="#c8ff00"
strokeOpacity="0.35"
strokeWidth="1"
strokeDasharray="2 6"
/>
{/* crosshair: takes the color of the side it reads */}
{readout && (
<g>
<line
x1={readout.x}
y1={TOP_Y - 8}
x2={readout.x}
y2={BASE_Y}
stroke={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}
strokeOpacity="0.6"
strokeWidth="1"
/>
<circle
cx={readout.x}
cy={readout.y}
r="3.5"
fill={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}
/>
<g
transform={`translate(${
readout.x > W - 190 ? readout.x - 178 : readout.x + 12
}, ${Math.max(readout.y - 40, TOP_Y)})`}
>
<rect width="166" height="34" fill="#161616" stroke="#3d3d3d" strokeWidth="1" />
<text
x="10"
y="21"
fill="#f2f2f2"
fontSize="11"
fontFamily="JetBrains Mono, monospace"
>
{readout.pct}
<tspan fill="#5c5c5c"> · </tspan>
<tspan fill={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}>
{readout.cum}
</tspan>
<tspan fill="#5c5c5c"> {readout.side}</tspan>
</text>
</g>
</g>
)}
</svg>
);
}

View file

@ -0,0 +1,73 @@
/**
* 30-day depth sparkline. Historical points are estimated: a deterministic,
* per-market random walk that converges to the current live depth figure, so
* the line is stable across renders and always ends at truth.
*/
const W = 220;
const H = 48;
const DAYS = 30;
function seeded(i: number, salt: number) {
const x = Math.sin(i * 91.7 + salt * 47.3) * 24634.5453;
return x - Math.floor(x);
}
export function DepthHistory({
currentUsd,
seedKey,
}: {
currentUsd: number;
seedKey: string;
}) {
if (currentUsd <= 0) return null;
const salt = [...seedKey].reduce((s, ch) => s + ch.charCodeAt(0), 0);
// walk backwards from today: depth was generally lower in the past
const values: number[] = [currentUsd];
for (let i = 1; i < DAYS; i++) {
const drift = 1 - 0.012 * i; // gentle growth trend toward today
const noise = 0.9 + seeded(i, salt) * 0.2;
values.push(currentUsd * Math.max(drift * noise, 0.35));
}
values.reverse();
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min || 1;
const pts = values
.map((v, i) => {
const x = (i / (DAYS - 1)) * W;
const y = H - 6 - ((v - min) / span) * (H - 12);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-12 w-full"
aria-hidden
style={{ filter: "drop-shadow(0 0 4px rgba(200, 255, 0, 0.45))" }}
>
<polyline
points={pts}
fill="none"
stroke="#c8ff00"
strokeWidth="1.5"
strokeOpacity="0.85"
/>
<polygon
points={`0,${H} ${pts} ${W},${H}`}
fill="#c8ff00"
opacity="0.045"
/>
<line x1="0" y1={H - 0.5} x2={W} y2={H - 0.5} stroke="#2a2a2a" strokeWidth="1" />
<circle
cx={W}
cy={H - 6 - ((values[DAYS - 1] - min) / span) * (H - 12)}
r="2.5"
fill="#c8ff00"
/>
</svg>
);
}

View file

@ -0,0 +1,50 @@
/**
* Depth bar: current pool depth vs campaign target, rendered as stacked
* order-book style ladder segments.
*/
export function DepthProgress({
progress,
segments = 24,
}: {
progress: number; // 0..1
segments?: number;
}) {
const filled = Math.round(Math.min(Math.max(progress, 0), 1) * segments);
return (
<svg
viewBox={`0 0 ${segments * 8} 10`}
className="h-2.5 w-full"
preserveAspectRatio="none"
aria-hidden
style={
filled > 0
? { filter: "drop-shadow(0 0 3px rgba(200, 255, 0, 0.4))" }
: undefined
}
>
{Array.from({ length: segments }, (_, i) => (
<rect
key={i}
x={i * 8}
y={0}
width={6}
height={10}
fill={i < filled ? "#c8ff00" : "#2a2a2a"}
opacity={i < filled ? 0.35 + (0.65 * i) / segments : 1}
>
{i < filled && (
<animate
attributeName="opacity"
from="0"
to={0.35 + (0.65 * i) / segments}
dur="0.4s"
begin={`${i * 0.02}s`}
fill="freeze"
/>
)}
</rect>
))}
</svg>
);
}

View file

@ -0,0 +1,30 @@
import { type ReactNode } from "react";
/**
* Live footnote apparatus: an acid superscript on a human statement that
* resolves to a mono line bound to onchain data. The marker never renders
* when the figure is not live, so an annotation can never go stale.
*/
export function FootnoteMark({ n, live }: { n: number; live: boolean }) {
if (!live) return null;
return <sup className="footnote-sup">{n}</sup>;
}
export function FootnoteLine({
n,
live,
children,
}: {
n: number;
live: boolean;
children: ReactNode;
}) {
if (!live) return null;
return (
<div className="num flex items-baseline gap-2 text-[11px] text-fog">
<span className="text-mint">{n}</span>
<span className="text-fog-dim">//</span>
{children}
</div>
);
}

View file

@ -0,0 +1,58 @@
import { Fragment } from "react";
const ITEMS = [
"Add liquidity",
"◎",
"Earn fees",
"✕",
"Make markets deeper",
"⊡",
"Depth vaults",
"✳",
"Stock tokens",
"+",
];
/** Brutalist running line: solid and outlined Tanker words with glyph separators. */
export function GlyphMarquee() {
const strip = (
<>
{ITEMS.map((item, i) => {
const glyph = item.length <= 2;
const outlined = !glyph && i % 4 === 2;
return (
<Fragment key={i}>
<span
className={
glyph
? "mx-6 text-lg text-mint/40"
: "font-display mx-6 text-2xl uppercase md:text-3xl"
}
style={
outlined
? { WebkitTextStroke: "1px rgba(233,255,179,0.5)", color: "transparent" }
: glyph
? undefined
: { color: "rgba(233,255,179,0.85)" }
}
>
{item}
</span>
</Fragment>
);
})}
</>
);
return (
<div
aria-hidden
className="overflow-hidden border-y border-edge bg-void py-4"
>
<div className="flex w-max animate-marquee items-center">
<div className="flex shrink-0 items-center">{strip}{strip}</div>
<div className="flex shrink-0 items-center">{strip}{strip}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { healthLabel } from "@/lib/math";
/** SVG radial gauge for the Market Health Score. */
export function HealthRadial({ score }: { score: number }) {
const { label, tone } = healthLabel(score);
const color = tone === "mint" ? "#c8ff00" : tone === "violet" ? "#8a5cff" : "#ff3b00";
const r = 34;
const c = 2 * Math.PI * r;
const filled = (score / 100) * c * 0.75; // 270 degree arc
return (
<div className="flex items-center gap-3">
<svg viewBox="0 0 80 80" className="h-16 w-16" aria-hidden>
<g transform="rotate(135 40 40)">
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke="#2a2a2a"
strokeWidth="6"
strokeDasharray={`${c * 0.75} ${c}`}
strokeLinecap="round"
/>
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={`${filled} ${c}`}
strokeLinecap="butt"
style={{ filter: `drop-shadow(0 0 4px ${color})` }}
>
<animate
attributeName="stroke-dasharray"
from={`0 ${c}`}
to={`${filled} ${c}`}
dur="0.9s"
fill="freeze"
calcMode="spline"
keySplines="0.25 0.1 0.25 1"
/>
</circle>
</g>
<text
x="40"
y="44"
textAnchor="middle"
fill="#f2f2f2"
fontSize="20"
fontWeight="600"
fontFamily="JetBrains Mono, monospace"
>
{score}
</text>
</svg>
<div>
<div className="text-sm font-semibold" style={{ color }}>
{label}
</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
Health score
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { parseUnits } from "viem";
import { useWaitForTransactionReceipt, useWriteContract } from "wagmi";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
import { fmtUsd, fmtCountdown, fmtNum } from "@/lib/format";
import { campaignPoints, isGenesisLp } from "@/lib/points";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CountUp } from "@/components/CountUp";
import type { CampaignState } from "@/hooks/useCampaigns";
import type { usePosition } from "@/hooks/useCampaigns";
type Position = NonNullable<ReturnType<typeof usePosition>["position"]>;
export function PositionPanel({
campaign,
position,
onSettled,
}: {
campaign: CampaignState;
position: Position;
onSettled: () => void;
}) {
const deployment = getActiveDeployment();
const { writeContract, data: txHash, isPending, reset } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
if (isSuccess) {
reset();
onSettled();
}
const busy = isPending || isConfirming;
const ended = campaign.status === "ended";
const id = BigInt(campaign.meta.campaignId);
const exit = () =>
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "withdraw",
args: [id, parseUnits(position.amountUsd.toString(), USD_DECIMALS)],
chainId: ACTIVE_CHAIN_ID,
});
const claim = () =>
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "claim",
args: [id],
chainId: ACTIVE_CHAIN_ID,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="slabel">LP receipt</div>
<div className="flex gap-2">
{isGenesisLp(position.lpIndex) && <Badge tone="mint">Genesis LP</Badge>}
{position.boosted && <Badge tone="lime">1.5x boosted</Badge>}
</div>
</div>
<div className="space-y-2.5 text-sm">
<div className="leader">
<span className="text-fog">Deposited</span>
<span className="num font-semibold">{fmtUsd(position.amountUsd, false)}</span>
</div>
<div className="leader">
<span className="text-fog">Vault share</span>
<span className="num font-semibold">
{campaign.tvlUsd > 0
? `${((position.amountUsd / campaign.tvlUsd) * 100).toFixed(1)}%`
: "0%"}
</span>
</div>
<div className="leader">
<span className="text-fog">Rewards accrued</span>
<span className="num font-semibold text-mint">
{fmtUsd(position.pendingUsd, false)}
</span>
</div>
<div className="leader">
<span className="text-fog">Rewards claimed</span>
<span className="num font-semibold">{fmtUsd(position.claimedUsd, false)}</span>
</div>
{position.stockAmount > 0 && (
<div className="leader">
<span className="text-fog">Stock leg</span>
<span className="num font-semibold">
{position.stockAmount.toFixed(4)} {campaign.meta.symbol}
</span>
</div>
)}
<div className="leader">
<span className="text-fog">Points</span>
<span className="num font-semibold text-lime">
<CountUp
value={campaignPoints({
amountUsd: position.amountUsd,
depositedAt: position.depositedAt,
boosted: position.boosted,
})}
format={(n) => fmtNum(Math.floor(n))}
/>
</span>
</div>
<div className="leader">
<span className="text-fog">Full rewards unlock</span>
<span className="num font-semibold">
{ended ? "Unlocked" : fmtCountdown(campaign.end)}
</span>
</div>
</div>
<div className="flex gap-3 pt-1">
{ended ? (
<>
<Button
className="flex-1"
disabled={busy || position.pendingUsd === 0}
onClick={claim}
>
{busy ? "Confirming..." : "Claim rewards"}
</Button>
<Button
className="flex-1"
variant="outline"
disabled={busy || position.amountUsd === 0}
onClick={exit}
>
Withdraw all
</Button>
</>
) : (
<Button
className="flex-1"
variant="danger"
disabled={busy || position.amountUsd === 0}
onClick={exit}
>
{busy ? "Confirming..." : "Exit early"}
</Button>
)}
</div>
{!ended ? (
<p className="text-[11px] leading-relaxed text-fog-dim">
Early exit returns your principal minus a 0.1% fee. Accrued campaign
rewards are forfeited and redistributed to remaining LPs.
</p>
) : (
<p className="text-[11px] leading-relaxed text-fog-dim">
Reward payouts carry a 10% platform fee. Points have no monetary
value and feed future programs.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
const BARS = [
{ w: 44, delay: 0 },
{ w: 30, delay: 0.08 },
{ w: 38, delay: 0.16 },
{ w: 24, delay: 0.24 },
{ w: 50, delay: 0.32 },
];
/** Fullscreen depth-bar preloader, shown once per session. */
export function Preloader() {
const [done, setDone] = useState(() => sessionStorage.getItem("hq-loaded") === "1");
useEffect(() => {
if (done) return;
const t = setTimeout(() => {
sessionStorage.setItem("hq-loaded", "1");
setDone(true);
}, 1600);
return () => clearTimeout(t);
}, [done]);
return (
<AnimatePresence>
{!done && (
<motion.div
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-6 bg-void"
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
>
<svg viewBox="0 0 64 48" className="h-16 w-20" aria-hidden>
{BARS.map((b, i) => (
<rect key={i} x="4" y={4 + i * 9} height="5" fill="#c8ff00" width={b.w}>
<animate
attributeName="width"
values={`6;${b.w};6`}
dur="1.4s"
begin={`${b.delay}s`}
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1"
/>
<animate
attributeName="opacity"
values="0.35;1;0.35"
dur="1.4s"
begin={`${b.delay}s`}
repeatCount="indefinite"
/>
</rect>
))}
</svg>
<div className="num text-[11px] tracking-[0.3em] text-fog uppercase">
Deepening markets
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,32 @@
import { slippageFor } from "@/lib/math";
import { fmtUsd } from "@/lib/format";
const SIZES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000];
/** Horizontal bars: estimated slippage per trade size at current depth. */
export function SlippageCurve({ liquidityUsd }: { liquidityUsd: number }) {
const max = slippageFor(SIZES[SIZES.length - 1], liquidityUsd);
return (
<div className="space-y-2">
{SIZES.map((size) => {
const s = slippageFor(size, liquidityUsd);
const width = max > 0 ? (s / max) * 100 : 0;
const hot = s > 0.02;
return (
<div key={size} className="flex items-center gap-3 text-xs">
<span className="num w-14 shrink-0 text-right text-fog">{fmtUsd(size)}</span>
<svg className="h-3 flex-1" preserveAspectRatio="none" viewBox="0 0 100 10" aria-hidden>
<rect x="0" y="3" width="100" height="4" fill="#2a2a2a" />
<rect x="0" y="3" width={Math.max(width, 1)} height="4" fill={hot ? "#ff3b00" : "#c8ff00"}>
<animate attributeName="width" from="0" to={Math.max(width, 1)} dur="0.7s" fill="freeze" />
</rect>
</svg>
<span className={`num w-14 shrink-0 ${hot ? "text-danger" : "text-mint"}`}>
{(s * 100).toFixed(2)}%
</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,37 @@
import { type ReactNode } from "react";
/**
* Defined-term gloss: a dotted-underline mechanism word that opens a
* plain-language definition card with exactly one risk sentence.
* Compliance as apparatus, not smallprint.
*/
export function Term({
def,
risk,
children,
}: {
def: string;
risk?: string;
children: ReactNode;
}) {
return (
<span className="group relative inline-block">
<span tabIndex={0} className="term outline-none focus-visible:text-mint">
{children}
</span>
<span
role="tooltip"
className="pointer-events-none invisible absolute bottom-full left-1/2 z-40 mb-2 w-[280px] -translate-x-1/2 border border-edge-bright bg-card p-3.5 text-left opacity-0 transition-opacity duration-150 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100"
>
<span className="block text-[12px] normal-case leading-relaxed tracking-normal text-snow">
{def}
</span>
{risk && (
<span className="mt-2 block font-mono text-[10px] uppercase leading-relaxed tracking-[0.04em] text-fog-dim">
{risk}
</span>
)}
</span>
</span>
);
}

View file

@ -0,0 +1,34 @@
import { useAllMarketData } from "@/hooks/useMarketData";
import { getActiveDeployment } from "@/lib/contracts";
import { cn } from "@/lib/utils";
/** Static strip of live tokenized stock prices. */
export function TickerTape() {
const { markets } = getActiveDeployment();
const { data } = useAllMarketData(markets);
const items = markets
.map((m) => ({ meta: m, pair: data?.[m.symbol] }))
.filter((x) => x.pair);
if (items.length === 0) return null;
return (
<div className="overflow-x-auto border-b border-edge bg-surface py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto flex w-max items-center gap-2 px-4 md:justify-center">
{items.map(({ meta, pair }) => (
<span
key={meta.symbol}
className="num inline-flex items-center gap-2 px-4 text-xs"
>
<span className="font-semibold text-snow">{meta.symbol}</span>
<span className="text-fog">${pair!.priceUsd.toFixed(2)}</span>
<span className={cn(pair!.change24h >= 0 ? "text-mint" : "text-danger")}>
{pair!.change24h >= 0 ? "▲" : "▼"} {Math.abs(pair!.change24h).toFixed(2)}%
</span>
</span>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
/**
* Ambient background: layered market-depth contours drifting slowly.
* Pure SVG, animated with SMIL so it ships as vector all the way.
*/
export function DepthField({ flip = false }: { flip?: boolean }) {
const waves = [
{ d: "M0 320 C 240 260, 420 380, 720 330 S 1200 260, 1440 320", o: 0.5, dur: "26s" },
{ d: "M0 360 C 260 300, 480 420, 760 370 S 1180 300, 1440 360", o: 0.35, dur: "32s" },
{ d: "M0 400 C 280 340, 520 460, 800 410 S 1160 340, 1440 400", o: 0.22, dur: "38s" },
{ d: "M0 440 C 300 380, 560 500, 840 450 S 1140 380, 1440 440", o: 0.12, dur: "44s" },
];
return (
<svg
viewBox="0 0 1440 560"
preserveAspectRatio="xMidYMax slice"
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full"
style={flip ? { transform: "scaleY(-1)" } : undefined}
>
<defs>
<linearGradient id="df-fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0" />
<stop offset="100%" stopColor="#c8ff00" stopOpacity="1" />
</linearGradient>
<mask id="df-mask">
<rect width="1440" height="560" fill="url(#df-fade)" />
</mask>
</defs>
<g mask="url(#df-mask)">
{waves.map((w, i) => (
<path
key={i}
d={w.d}
fill="none"
stroke="#c8ff00"
strokeOpacity={w.o}
strokeWidth={i === 0 ? 1.5 : 1}
>
<animateTransform
attributeName="transform"
type="translate"
values="0 0; -36 14; 0 0"
dur={w.dur}
repeatCount="indefinite"
/>
</path>
))}
{/* depth grid ticks */}
{Array.from({ length: 24 }, (_, i) => (
<line
key={`t${i}`}
x1={i * 62 + 20}
y1={520}
x2={i * 62 + 20}
y2={528}
stroke="#c8ff00"
strokeOpacity="0.25"
strokeWidth="1"
/>
))}
</g>
</svg>
);
}

View file

@ -0,0 +1,71 @@
/**
* Hero visual: an order-book depth ladder filling up, pure SVG animation.
* Bids in mint grow from the right, asks in white grow from the left,
* meeting around the spread line.
*/
const BIDS = [86, 64, 74, 52, 60, 42, 30];
const ASKS = [80, 58, 68, 48, 54, 38, 26];
export function DepthLadder() {
return (
<svg viewBox="0 0 420 380" className="h-full w-full" aria-hidden>
<defs>
<linearGradient id="dl-bid" x1="1" y1="0" x2="0" y2="0">
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0.9" />
<stop offset="100%" stopColor="#c8ff00" stopOpacity="0.15" />
</linearGradient>
<linearGradient id="dl-ask" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#f2f2f2" stopOpacity="0.55" />
<stop offset="100%" stopColor="#f2f2f2" stopOpacity="0.08" />
</linearGradient>
</defs>
{/* spread line */}
<line x1="210" y1="10" x2="210" y2="370" stroke="#3d3d3d" strokeWidth="1" strokeDasharray="3 6" />
{BIDS.map((w, i) => {
const y = 24 + i * 50;
const bid = w * 2;
const ask = ASKS[i] * 2;
return (
<g key={i}>
<rect x={210 - bid} y={y} width={bid} height="22" fill="url(#dl-bid)">
<animate
attributeName="width"
values={`${bid * 0.55};${bid};${bid * 0.75};${bid}`}
dur={`${5 + i * 0.9}s`}
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
/>
<animate
attributeName="x"
values={`${210 - bid * 0.55};${210 - bid};${210 - bid * 0.75};${210 - bid}`}
dur={`${5 + i * 0.9}s`}
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
/>
</rect>
<rect x="212" y={y + 26} width={ask} height="22" fill="url(#dl-ask)">
<animate
attributeName="width"
values={`${ask};${ask * 0.6};${ask * 0.85};${ask}`}
dur={`${6 + i * 0.7}s`}
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
/>
</rect>
</g>
);
})}
{/* price tick riding the spread */}
<circle cx="210" cy="190" r="4" fill="#e9ffb3">
<animate attributeName="cy" values="60;320;140;260;60" dur="14s" repeatCount="indefinite" />
<animate attributeName="opacity" values="1;0.5;1;0.6;1" dur="14s" repeatCount="indefinite" />
</circle>
</svg>
);
}

View file

@ -0,0 +1,42 @@
/**
* Soft acid glow blobs with thin technical hairlines over them,
* lifted from the reference posters.
*/
export function GlowField() {
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 right-[-10%] h-[480px] w-[480px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(200,255,0,0.22) 0%, rgba(200,255,0,0.07) 45%, transparent 70%)",
filter: "blur(40px)",
}}
/>
<div
className="absolute bottom-[-30%] left-[-8%] h-[420px] w-[420px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(200,255,0,0.14) 0%, rgba(200,255,0,0.05) 50%, transparent 72%)",
filter: "blur(48px)",
}}
/>
{/* technical hairlines with tick ends */}
<svg className="absolute inset-0 h-full w-full" aria-hidden>
<g stroke="#f2f2f2" strokeOpacity="0.18" strokeWidth="1">
<line x1="12%" y1="0" x2="12%" y2="62%" />
<line x1="4%" y1="34%" x2="58%" y2="34%" />
<line x1="46%" y1="18%" x2="46%" y2="100%" />
<line x1="30%" y1="78%" x2="96%" y2="78%" />
</g>
<g fill="#f2f2f2" fillOpacity="0.35">
<rect x="calc(12% - 3px)" y="calc(62% - 1px)" width="6" height="2" />
<rect x="calc(4% - 1px)" y="calc(34% - 3px)" width="2" height="6" />
<rect x="calc(46% - 3px)" y="calc(18% - 1px)" width="6" height="2" />
<rect x="calc(96% - 1px)" y="calc(78% - 3px)" width="2" height="6" />
</g>
</svg>
</div>
);
}

View file

@ -0,0 +1,52 @@
/**
* Marathon-style glyph grid: patches of terminal symbols scattered over the
* section. Built from one SVG pattern tile, so it stays cheap to render.
*/
export function GlyphField({ opacity = 0.4 }: { opacity?: number }) {
return (
<svg
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ opacity }}
>
<defs>
<pattern id="glyphs" width="112" height="84" patternUnits="userSpaceOnUse">
<g
fill="#c8ff00"
fontFamily="JetBrains Mono, monospace"
fontSize="11"
opacity="0.55"
>
<text x="4" y="12"></text>
<text x="32" y="12" opacity="0.5"></text>
<text x="60" y="12"></text>
<text x="88" y="12" opacity="0.35">+</text>
<text x="18" y="36" opacity="0.4">·</text>
<text x="46" y="36"></text>
<text x="74" y="36" opacity="0.6"></text>
<text x="102" y="36" opacity="0.3"></text>
<text x="4" y="60" opacity="0.5"></text>
<text x="32" y="60" opacity="0.3">+</text>
<text x="60" y="60"></text>
<text x="88" y="60" opacity="0.45"></text>
<text x="18" y="80" opacity="0.35"></text>
<text x="74" y="80" opacity="0.25">·</text>
</g>
</pattern>
<radialGradient id="glyph-patch-a" cx="0.2" cy="0.25" r="0.5">
<stop offset="0%" stopColor="#fff" />
<stop offset="100%" stopColor="#000" />
</radialGradient>
<radialGradient id="glyph-patch-b" cx="0.85" cy="0.75" r="0.45">
<stop offset="0%" stopColor="#fff" />
<stop offset="100%" stopColor="#000" />
</radialGradient>
<mask id="glyph-mask">
<rect width="100%" height="100%" fill="url(#glyph-patch-a)" />
<rect width="100%" height="100%" fill="url(#glyph-patch-b)" style={{ mixBlendMode: "screen" }} />
</mask>
</defs>
<rect width="100%" height="100%" fill="url(#glyphs)" mask="url(#glyph-mask)" />
</svg>
);
}

View file

@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
/**
* Mark: an "H" built from order-book depth bars.
* Left column = bids, right column = asks, crossbar = the spread being filled.
*/
export function LogoMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
className={cn("h-8 w-8", className)}
aria-hidden
>
<rect x="3" y="4" width="10" height="3" fill="#c8ff00" />
<rect x="3" y="10" width="7" height="3" fill="#c8ff00" opacity="0.75" />
<rect x="3" y="16" width="9" height="3" fill="#c8ff00" opacity="0.55" />
<rect x="3" y="22" width="6" height="3" fill="#c8ff00" opacity="0.35" />
<rect x="19" y="4" width="10" height="3" fill="#f2f2f2" opacity="0.9" />
<rect x="22" y="10" width="7" height="3" fill="#f2f2f2" opacity="0.6" />
<rect x="20" y="16" width="9" height="3" fill="#f2f2f2" opacity="0.45" />
<rect x="23" y="22" width="6" height="3" fill="#f2f2f2" opacity="0.3" />
<rect x="10" y="13" width="12" height="3" fill="#e9ffb3" />
</svg>
);
}
export function Wordmark({ className }: { className?: string }) {
return (
<span
className={cn(
"font-display text-lg font-bold tracking-tight text-snow",
className,
)}
>
Hood<span className="text-mint">quidity</span>
</span>
);
}

View file

@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { motionEnabled } from "@/lib/motion";
const GLYPHS =
"アイウエオカキクケコサシスセソタチツテトナニヌネハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789▚▞▮◎";
/**
* Cyberdeck decode: the label periodically dissolves into katakana and
* terminal glyphs, then settles back left to right. Decorative labels only,
* never live figures.
*/
export function Scramble({
text,
interval = 9000,
className,
}: {
text: string;
interval?: number;
className?: string;
}) {
const [display, setDisplay] = useState(text);
const raf = useRef(0);
useEffect(() => {
if (!motionEnabled) {
setDisplay(text);
return;
}
let cancelled = false;
const decode = () => {
const start = performance.now();
const dur = 950;
const tick = (t: number) => {
if (cancelled) return;
const p = Math.min((t - start) / dur, 1);
const settled = Math.floor(p * text.length);
let out = text.slice(0, settled);
for (let i = settled; i < text.length; i++) {
const ch = text[i];
out +=
ch === " "
? " "
: GLYPHS[(Math.floor(t / 48) + i * 7) % GLYPHS.length];
}
setDisplay(out);
if (p < 1) raf.current = requestAnimationFrame(tick);
else setDisplay(text);
};
raf.current = requestAnimationFrame(tick);
};
decode();
const jitter = (text.length * 137) % 3000;
const timer = setInterval(decode, interval + jitter);
return () => {
cancelled = true;
clearInterval(timer);
cancelAnimationFrame(raf.current);
};
}, [text, interval]);
return <span className={className}>{display}</span>;
}

View file

@ -0,0 +1,33 @@
/**
* Vertical neon signage on the hero's right rail: kanji street sign over a
* mono locator stack and a barcode glyph run. Pure decoration, Marathon rail
* grammar with a Kabukicho accent.
*/
export function SideRail() {
return (
<div
aria-hidden
className="pointer-events-none absolute right-4 top-36 hidden select-none flex-col items-center gap-6 xl:flex"
>
<span
className="neon text-2xl font-semibold tracking-[0.3em]"
style={{ writingMode: "vertical-rl" }}
>
</span>
<span
className="font-mono text-[9px] uppercase tracking-[0.3em] text-fog-dim"
style={{ writingMode: "vertical-rl" }}
>
Hoodquidity // Depth Desk // RH-L2 4663
</span>
<span className="font-mono text-[10px] leading-[0.85] tracking-[0.02em] text-mint/50">
<br />
<br />
</span>
</div>
);
}

View file

@ -0,0 +1,7 @@
export function XIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
import { type ReactNode } from "react";
import { Header } from "./Header";
import { Footer } from "./Footer";
import { Preloader } from "@/components/Preloader";
import { TickerTape } from "@/components/TickerTape";
import { MobileNav } from "./MobileNav";
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="grain flex min-h-screen flex-col">
<Preloader />
<Header />
<TickerTape />
<main className="flex-1 pb-16 md:pb-0">{children}</main>
<Footer />
<MobileNav />
</div>
);
}

View file

@ -0,0 +1,81 @@
import { Link } from "react-router-dom";
import { LogoMark, Wordmark } from "@/components/brand/Logo";
import { XIcon } from "@/components/icons/XIcon";
import { TWITTER_URL } from "@/lib/constants";
import { getActiveDeployment, ACTIVE_CHAIN_ID } from "@/lib/contracts";
export function Footer() {
return (
<footer className="border-t border-edge bg-surface">
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
<div className="flex flex-col gap-10 md:flex-row md:items-start md:justify-between">
<div className="max-w-sm space-y-4">
<div className="flex items-center gap-2.5">
<LogoMark />
<Wordmark />
<span className="ml-1 font-mono text-[10px] tracking-[0.2em] text-fog-dim/70">
</span>
</div>
<p className="text-sm leading-relaxed text-fog">
Liquidity campaigns for Stock Token markets on Robinhood Chain.
Deposit stablecoins, receive an LP receipt and earn from deeper
markets.
</p>
<a
href={TWITTER_URL}
target="_blank"
rel="noreferrer"
aria-label="Hoodquidity on X"
className="inline-flex items-center gap-2 text-sm text-fog transition-colors hover:text-mint"
>
<XIcon className="h-4 w-4" />
Follow on X <span className="font-mono text-[10px] text-fog-dim">[]</span>
</a>
</div>
<div className="grid grid-cols-2 gap-10 text-sm sm:gap-16">
<div className="space-y-3">
<div className="slabel">App</div>
<ul className="space-y-2.5">
<li><Link className="text-fog transition-colors hover:text-snow" to="/campaigns">Campaigns</Link></li>
<li><Link className="text-fog transition-colors hover:text-snow" to="/markets">Markets</Link></li>
<li><Link className="text-fog transition-colors hover:text-snow" to="/positions">Positions</Link></li>
</ul>
</div>
<div className="space-y-3">
<div className="slabel">Protocol</div>
<ul className="space-y-2.5">
<li><span className="cursor-default text-fog-dim">Docs, soon</span></li>
<li><Link className="text-fog transition-colors hover:text-snow" to="/partners">Partner portal</Link></li>
<li>
<a className="text-fog transition-colors hover:text-snow" href={TWITTER_URL} target="_blank" rel="noreferrer">
Twitter / X
</a>
</li>
</ul>
</div>
</div>
</div>
<div className="mt-12 flex flex-col gap-3 border-t border-edge pt-6 text-xs text-fog-dim md:flex-row md:items-center md:justify-between">
<span className="num">HOODQUIDITY / {new Date().getFullYear()}</span>
<span className="max-w-xl leading-relaxed">
LP positions may lose value. Trading fees depend on volume, rewards
are subject to campaign terms. Market availability depends on your
jurisdiction.
</span>
</div>
<div className="mt-4 flex flex-col gap-1.5 text-[10px] text-fog-dim md:flex-row md:items-center md:justify-between">
<span className="num break-all">
VAULT <span className="text-fog-dim">//</span>{" "}
<span className="text-fog">{getActiveDeployment().vaults}</span>{" "}
<span className="text-fog-dim">// CHAIN {ACTIVE_CHAIN_ID}</span>
</span>
<span className="num shrink-0">BUILD E-{new Date().getFullYear()}.07 / DESK 00</span>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,86 @@
import { NavLink, Link } from "react-router-dom";
import { useAppKit, useAppKitAccount, useAppKitNetwork } from "@reown/appkit/react";
import { Button } from "@/components/ui/button";
import { LogoMark, Wordmark } from "@/components/brand/Logo";
import { XIcon } from "@/components/icons/XIcon";
import { TWITTER_URL } from "@/lib/constants";
import { cn } from "@/lib/utils";
const NAV = [
{ to: "/app", label: "App" },
{ to: "/campaigns", label: "Campaigns" },
{ to: "/markets", label: "Markets" },
{ to: "/positions", label: "Positions" },
];
function ConnectControl() {
const { open } = useAppKit();
const { address, isConnected } = useAppKitAccount();
const { chainId } = useAppKitNetwork();
if (!isConnected || !address) {
return <Button onClick={() => open()}>Connect Wallet</Button>;
}
return (
<div className="flex items-center gap-2">
<button
onClick={() => open({ view: "Networks" })}
className="num cursor-pointer border border-edge px-2.5 py-2 font-mono text-[10px] uppercase tracking-[0.08em] text-fog transition-colors hover:border-mint hover:text-mint"
>
{chainId === 46_630
? "RH Testnet"
: chainId === 31_337
? "Anvil"
: chainId === 421_614
? "Arb Sepolia"
: `Chain ${chainId ?? "?"}`}
</button>
<Button size="md" variant="outline" onClick={() => open()}>
{address.slice(0, 6)}...{address.slice(-4)}
</Button>
</div>
);
}
export function Header() {
return (
<header className="sticky top-0 z-50 border-b border-edge bg-void/80 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 md:px-8">
<Link to="/" className="flex items-center gap-2.5">
<LogoMark />
<Wordmark className="hidden sm:inline" />
</Link>
<nav className="hidden items-center gap-7 md:flex">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-snow",
isActive && "text-mint hover:text-mint",
)
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-3">
<a
href={TWITTER_URL}
target="_blank"
rel="noreferrer"
aria-label="Hoodquidity on X"
className="text-fog transition-colors hover:text-mint"
>
<XIcon className="h-4.5 w-4.5" />
</a>
<ConnectControl />
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,71 @@
import { NavLink } from "react-router-dom";
import { cn } from "@/lib/utils";
const ITEMS = [
{
to: "/",
label: "Home",
icon: (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
<path d="M3 10.5 10 4l7 6.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 9.5V16h10V9.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
to: "/campaigns",
label: "Campaigns",
icon: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
<rect x="3" y="4" width="9" height="2.4" />
<rect x="3" y="8.8" width="14" height="2.4" />
<rect x="3" y="13.6" width="6" height="2.4" />
</svg>
),
},
{
to: "/markets",
label: "Markets",
icon: (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
<path d="M3 16 8 10l3 3 6-8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
to: "/positions",
label: "Positions",
icon: (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
<rect x="3.5" y="5" width="13" height="11" />
<path d="M3.5 9h13" />
</svg>
),
},
];
/** Bottom tab bar, mobile only. */
export function MobileNav() {
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-edge bg-void/90 backdrop-blur-xl md:hidden">
<div className="grid grid-cols-4">
{ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"flex flex-col items-center gap-1 py-2.5 text-[10px] font-medium uppercase tracking-wider transition-colors",
isActive ? "text-mint" : "text-fog-dim",
)
}
>
{item.icon}
{item.label}
</NavLink>
))}
</div>
</nav>
);
}

View file

@ -0,0 +1,41 @@
import { type HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-none px-2.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider",
{
variants: {
tone: {
mint: "bg-mint-soft text-mint",
fog: "bg-edge text-fog",
danger: "bg-danger/10 text-danger",
lime: "bg-lime/10 text-lime",
},
},
defaultVariants: { tone: "fog" },
},
);
interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
pulse?: boolean;
}
export function Badge({ className, tone, pulse, children, ...props }: BadgeProps) {
// Live states get a STATIC dot; only danger states may flicker
return (
<span
className={cn(
badgeVariants({ tone }),
pulse && tone === "danger" && "blink-risk",
className,
)}
{...props}
>
{pulse && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{children}
</span>
);
}

View file

@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-none font-mono font-semibold uppercase tracking-wider transition-all duration-200 focus-visible:outline-2 focus-visible:outline-mint disabled:pointer-events-none disabled:opacity-40",
{
variants: {
variant: {
primary:
"btn-sweep bg-mint text-void shadow-[0_0_20px_rgba(200,255,0,0.25)] hover:bg-lime hover:shadow-glow-strong active:scale-[0.98]",
outline:
"border border-edge-bright bg-transparent text-snow hover:border-mint hover:text-mint",
ghost: "text-fog hover:bg-card hover:text-snow",
danger:
"border border-danger/40 bg-danger/10 text-danger hover:bg-danger/20",
},
size: {
sm: "h-8 px-3 text-[11px]",
md: "h-10 px-5 text-xs",
lg: "h-12 px-7 text-sm",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
),
);
Button.displayName = "Button";

View file

@ -0,0 +1,14 @@
import { type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"ticks rounded-none border border-edge bg-card p-5 transition-colors duration-300",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,14 @@
import { type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-pulse rounded-none bg-gradient-to-r from-edge via-card-hover to-edge",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,24 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface StatProps {
label: string;
value: ReactNode;
hint?: string;
accent?: boolean;
className?: string;
}
export function Stat({ label, value, hint, accent, className }: StatProps) {
return (
<div className={cn("space-y-1", className)}>
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-fog-dim">
{label}
</div>
<div className={cn("num text-xl font-semibold", accent ? "text-mint" : "text-snow")}>
{value}
</div>
{hint && <div className="text-xs text-fog">{hint}</div>}
</div>
);
}

21
apps/web/src/ds-entry.ts Normal file
View file

@ -0,0 +1,21 @@
/**
* Design-system surface for design-sync: pure presentational components only.
* App-level pieces (router/wagmi-bound screens, panels, layout) are excluded
* on purpose they need live context the design tool doesn't have.
*/
export { Button } from "./components/ui/button";
export { Card } from "./components/ui/card";
export { Badge } from "./components/ui/badge";
export { Skeleton } from "./components/ui/skeleton";
export { Stat } from "./components/ui/stat";
export { LogoMark, Wordmark } from "./components/brand/Logo";
export { XIcon } from "./components/icons/XIcon";
export { DepthProgress } from "./components/DepthProgress";
export { HealthRadial } from "./components/HealthRadial";
export { SlippageCurve } from "./components/SlippageCurve";
export { CountUp } from "./components/CountUp";
export { GlyphMarquee } from "./components/GlyphMarquee";
export { DepthField } from "./components/backdrop/DepthField";
export { DepthLadder } from "./components/backdrop/DepthLadder";
export { GlowField } from "./components/backdrop/GlowField";
export { GlyphField } from "./components/backdrop/GlyphField";

View file

@ -0,0 +1,210 @@
import { useAccount, useReadContracts } from "wagmi";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import {
ACTIVE_CHAIN_ID,
getActiveDeployment,
USD_DECIMALS,
type MarketMeta,
} from "@/lib/contracts";
export interface CampaignState {
meta: MarketMeta;
tvlUsd: number;
targetDepthUsd: number;
rewardBudgetUsd: number;
start: number;
end: number;
boostWindow: number;
boostBps: number;
durationDays: number;
activeLPs: number;
status: "upcoming" | "boost" | "active" | "ended";
}
/** Budget still unreleased by the linear emission schedule, in USD. */
export function rewardsRemainingUsd(c: CampaignState, nowSec: number): number {
if (c.end <= c.start) return 0;
const left = Math.min(Math.max((c.end - nowSec) / (c.end - c.start), 0), 1);
return c.rewardBudgetUsd * left;
}
const scale = 10 ** USD_DECIMALS;
export function useCampaigns() {
const deployment = getActiveDeployment();
const { data, isLoading, error } = useReadContracts({
contracts: deployment.markets.map((m) => ({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "getCampaign",
args: [BigInt(m.campaignId)],
chainId: ACTIVE_CHAIN_ID,
})),
query: { refetchInterval: 15_000 },
});
const now = Math.floor(Date.now() / 1000);
const campaigns: CampaignState[] | undefined = data?.map((res, i) => {
const meta = deployment.markets[i];
const c = res.result as
| {
start: bigint;
end: bigint;
boostWindow: bigint;
boostBps: number;
targetDepth: bigint;
rewardBudget: bigint;
totalDeposits: bigint;
activeLPs: number;
}
| undefined;
if (!c) {
return {
meta,
tvlUsd: 0,
targetDepthUsd: 0,
rewardBudgetUsd: 0,
start: 0,
end: 0,
boostWindow: 0,
boostBps: 10_000,
durationDays: 0,
activeLPs: 0,
status: "upcoming" as const,
};
}
const start = Number(c.start);
const end = Number(c.end);
const boostWindow = Number(c.boostWindow);
let status: CampaignState["status"] = "active";
if (now < start) status = "upcoming";
else if (now < start + boostWindow) status = "boost";
else if (now >= end) status = "ended";
return {
meta,
tvlUsd: Number(c.totalDeposits) / scale,
targetDepthUsd: Number(c.targetDepth) / scale,
rewardBudgetUsd: Number(c.rewardBudget) / scale,
start,
end,
boostWindow,
boostBps: Number(c.boostBps),
durationDays: (end - start) / 86_400,
activeLPs: Number(c.activeLPs),
status,
};
});
return { campaigns, isLoading, error, deployment };
}
/** Positions across every campaign, for the portfolio board. */
export function usePositions() {
const { address } = useAccount();
const deployment = getActiveDeployment();
const zero = "0x0000000000000000000000000000000000000000" as const;
const { data, isLoading, refetch } = useReadContracts({
contracts: deployment.markets.flatMap((m) => [
{
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "getPosition",
args: [BigInt(m.campaignId), address ?? zero],
chainId: ACTIVE_CHAIN_ID,
} as const,
{
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "pendingRewards",
args: [BigInt(m.campaignId), address ?? zero],
chainId: ACTIVE_CHAIN_ID,
} as const,
]),
query: { enabled: !!address, refetchInterval: 15_000 },
});
const positions = deployment.markets.map((meta, i) => {
const raw = data?.[i * 2]?.result as
| {
amount: bigint;
weighted: bigint;
claimed: bigint;
stockAmount: bigint;
stockCredit: bigint;
depositedAt: bigint;
lpIndex: number;
}
| undefined;
const pending = data?.[i * 2 + 1]?.result as bigint | undefined;
return {
meta,
amountUsd: raw ? Number(raw.amount) / scale : 0,
claimedUsd: raw ? Number(raw.claimed) / scale : 0,
pendingUsd: pending != null ? Number(pending) / scale : 0,
boosted: raw ? raw.amount > 0n && raw.weighted > raw.amount : false,
depositedAt: raw ? Number(raw.depositedAt) : 0,
lpIndex: raw ? Number(raw.lpIndex) : 0,
stockAmount: raw ? Number(raw.stockAmount) / 1e18 : 0,
};
});
return { positions, isLoading, refetch };
}
export function usePosition(campaignId: number) {
const { address } = useAccount();
const deployment = getActiveDeployment();
const { data, isLoading, refetch } = useReadContracts({
contracts: [
{
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "getPosition",
args: [BigInt(campaignId), address ?? "0x0000000000000000000000000000000000000000"],
chainId: ACTIVE_CHAIN_ID,
},
{
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "pendingRewards",
args: [BigInt(campaignId), address ?? "0x0000000000000000000000000000000000000000"],
chainId: ACTIVE_CHAIN_ID,
},
],
query: { enabled: !!address, refetchInterval: 15_000 },
});
const raw = data?.[0]?.result as
| {
amount: bigint;
weighted: bigint;
claimed: bigint;
stockAmount: bigint;
stockCredit: bigint;
depositedAt: bigint;
lpIndex: number;
}
| undefined;
const pending = data?.[1]?.result as bigint | undefined;
return {
isLoading,
refetch,
position: raw
? {
amountUsd: Number(raw.amount) / scale,
weighted: Number(raw.weighted) / scale,
claimedUsd: Number(raw.claimed) / scale,
depositedAt: Number(raw.depositedAt),
lpIndex: Number(raw.lpIndex),
stockAmount: Number(raw.stockAmount) / 1e18,
stockCreditUsd: Number(raw.stockCredit) / scale,
pendingUsd: pending != null ? Number(pending) / scale : 0,
boosted: raw.amount > 0n && raw.weighted > raw.amount,
}
: undefined,
};
}

View file

@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { createPublicClient, http, formatGwei } from "viem";
import { mainnet } from "viem/chains";
import { STATS_RPC_URL } from "@/lib/chain";
const statsClient = createPublicClient({
chain: mainnet,
transport: http(STATS_RPC_URL),
});
/** Live L1 stats shown in the status strip. */
export function useChainStats() {
return useQuery({
queryKey: ["chain-stats"],
queryFn: async () => {
const [blockNumber, gasPrice] = await Promise.all([
statsClient.getBlockNumber(),
statsClient.getGasPrice(),
]);
return {
blockNumber: Number(blockNumber),
gasGwei: Number(formatGwei(gasPrice)),
};
},
refetchInterval: 15_000,
});
}

View file

@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { fetchPair, fetchBasket, type PairData } from "@/lib/dexscreener";
import type { MarketMeta } from "@/lib/contracts";
async function fetchMarket(meta: MarketMeta): Promise<PairData | null> {
if (meta.dexscreenerQuery.startsWith("basket:")) {
return fetchBasket(meta.dexscreenerQuery.slice("basket:".length).split(","));
}
return fetchPair(meta.dexscreenerQuery);
}
/** Live price / volume / liquidity for one market, refreshed every 30s. */
export function useMarketData(meta: MarketMeta) {
return useQuery({
queryKey: ["dexscreener", meta.dexscreenerQuery],
queryFn: () => fetchMarket(meta),
refetchInterval: 30_000,
staleTime: 25_000,
});
}
export function useAllMarketData(markets: MarketMeta[]) {
return useQuery({
queryKey: ["dexscreener-all", markets.map((m) => m.dexscreenerQuery).join()],
queryFn: async () => {
const results = await Promise.all(
markets.map((m) => fetchMarket(m).catch(() => null)),
);
return Object.fromEntries(
markets.map((m, i) => [m.symbol, results[i]]),
) as Record<string, PairData | null>;
},
refetchInterval: 30_000,
staleTime: 25_000,
});
}

400
apps/web/src/index.css Normal file
View file

@ -0,0 +1,400 @@
@import "tailwindcss";
/* ---------------------------------- fonts ---------------------------------- */
@font-face {
font-family: "Tanker";
src: url("/fonts/Tanker-Regular.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Cabinet Grotesk";
src: url("/fonts/CabinetGrotesk-Extrabold.woff2") format("woff2");
font-weight: 800;
font-display: swap;
}
@font-face {
font-family: "Cabinet Grotesk";
src: url("/fonts/CabinetGrotesk-Bold.woff2") format("woff2");
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "Switzer";
src: url("/fonts/Switzer-Regular.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Switzer";
src: url("/fonts/Switzer-Medium.woff2") format("woff2");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Switzer";
src: url("/fonts/Switzer-Semibold.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: "Panchang";
src: url("/fonts/Panchang-Extrabold.woff2") format("woff2");
font-weight: 800;
font-display: swap;
}
@font-face {
font-family: "Panchang";
src: url("/fonts/Panchang-Bold.woff2") format("woff2");
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "Sentient";
src: url("/fonts/Sentient-Light.woff2") format("woff2");
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: "Sentient";
src: url("/fonts/Sentient-LightItalic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Fragment Mono";
src: url("/fonts/FragmentMono-Regular.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/JetBrainsMono-SemiBold.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
/* ----------------------------- palette: BEARER ------------------------------
The only colors allowed in this codebase. Alpha variants of a token are
fine; any other literal fails `pnpm audit:palette`.
BASE (neutral ramp) void #080808 · surface #111111 · card #161616
card-hover #1c1c1c · edge #2a2a2a · edge-bright #3d3d3d
TEXT snow #f2f2f2 · fog #919191 · fog-dim #5c5c5c
ACID (value present) mint #c8ff00 · mint-dim #8fb000 · lime #e9ffb3 (lore)
VOLT (counter side) volt #5200ff (fills) · volt-soft #8a5cff (lines/text)
DANGER (risk only) danger #ff3b00, motion-paired via .blink-risk
Exempt: white/black stops inside SVG luminance masks (GlyphField).
----------------------------------------------------------------------------- */
@theme static {
/* Neutral black ramp, Marathon-faithful: no warm cast anywhere */
--color-void: #080808;
--color-surface: #111111;
--color-card: #161616;
--color-card-hover: #1c1c1c;
--color-edge: #2a2a2a;
--color-edge-bright: #3d3d3d;
/* Acid is semantic: value is present. Marathon C0FE04 x RH CCFF00 */
--color-mint: #c8ff00;
--color-mint-dim: #8fb000;
--color-mint-soft: rgba(200, 255, 0, 0.1);
/* Lore tint for display fragments, keeps pure acid for data */
--color-lime: #e9ffb3;
/* Neutral terminal grays (Marathon foreground-muted family) */
--color-fog: #919191;
--color-fog-dim: #5c5c5c;
--color-snow: #f2f2f2;
/* Marathon channels */
--color-volt: #5200ff;
--color-danger: #ff3b00;
--color-volt-soft: #8a5cff;
--font-display: "Panchang", "Cabinet Grotesk", system-ui, sans-serif;
--font-head: "Cabinet Grotesk", system-ui, sans-serif;
--font-serif: "Sentient", Georgia, serif;
--font-body: "Switzer", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-mono-label: "Fragment Mono", ui-monospace, monospace;
--shadow-glow: 0 0 28px rgba(200, 255, 0, 0.4);
--shadow-glow-strong: 0 0 52px rgba(200, 255, 0, 0.55);
--animate-marquee: marquee 24s linear infinite;
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
}
/* ----------------------------------- base ---------------------------------- */
html {
background: var(--color-void);
color: var(--color-snow);
font-family: var(--font-body);
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* Two-register rule: only super-type screams. H1 and .super are Panchang
caps; H2 section heads drop to sentence-case Cabinet, the Robinhood layer */
h1 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
text-transform: uppercase;
}
h2 {
font-family: var(--font-head);
font-weight: 700;
letter-spacing: -0.015em;
text-transform: none;
}
.super {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
text-transform: uppercase;
}
h3, h4 {
font-family: var(--font-head);
letter-spacing: -0.01em;
}
/* Certificate register: the one serif line per page. Never sets a digit */
.certificate {
font-family: var(--font-serif);
font-weight: 300;
font-style: italic;
letter-spacing: 0.005em;
text-transform: none;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--color-void); }
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--color-mint), var(--color-mint-dim));
border-radius: 8px;
border: 2px solid var(--color-void);
}
::-webkit-scrollbar-thumb:hover { background: var(--color-mint); }
* { scrollbar-width: thin; scrollbar-color: var(--color-mint-dim) var(--color-void); }
::selection { background: var(--color-mint); color: var(--color-void); }
/* ------------------------------- signatures -------------------------------- */
/* Film grain overlay: applied once in AppShell */
.grain::after {
content: "";
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Bayer-style dither texture, acid on transparent */
.dither {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cg fill='%23c8ff00'%3E%3Crect x='0' y='0' width='1' height='1'/%3E%3Crect x='4' y='2' width='1' height='1'/%3E%3Crect x='2' y='4' width='1' height='1'/%3E%3Crect x='6' y='6' width='1' height='1'/%3E%3C/g%3E%3C/svg%3E");
}
/* Terminal corner ticks for cards */
.ticks {
position: relative;
}
.ticks::before,
.ticks::after {
content: "";
position: absolute;
width: 7px;
height: 7px;
border-color: var(--color-edge-bright);
border-style: solid;
transition: border-color 0.3s;
}
.ticks::before {
top: -1px;
left: -1px;
border-width: 1px 0 0 1px;
}
.ticks::after {
bottom: -1px;
right: -1px;
border-width: 0 1px 1px 0;
}
.ticks:hover::before,
.ticks:hover::after {
border-color: var(--color-mint);
}
/* Armed ticks: acid corners plus a soft tube glow when value is live */
.ticks-live {
box-shadow:
0 0 2px rgba(200, 255, 0, 0.25),
0 0 26px rgba(200, 255, 0, 0.09);
}
.ticks-live::before,
.ticks-live::after {
border-color: var(--color-mint);
}
/* Neon numerals: key acid figures get the tube treatment */
.neon-num {
text-shadow:
0 0 10px rgba(200, 255, 0, 0.55),
0 0 32px rgba(200, 255, 0, 0.25);
}
/* Danger pairing law: the blink flicker is reserved for risk states and the
danger hue never appears static in it */
@keyframes blink-risk {
0%, 100% { opacity: 1; }
46%, 54% { opacity: 0.35; }
}
.blink-risk {
animation: blink-risk 1.6s steps(2, jump-none) infinite;
}
/* Defined-term gloss: dotted mechanism word opening a plain-language card */
.term {
cursor: help;
text-decoration: underline dotted var(--color-fog-dim);
text-underline-offset: 3px;
}
/* Section labels: // MARKETS. Fragment Mono = label voice, never digits */
.slabel {
font-family: var(--font-mono-label);
font-size: 0.6875rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--color-mint);
}
.slabel::before {
content: "// ";
color: var(--color-fog-dim);
}
/* Instrument-plate caption under charts: FIG. 01 // DEPTH // SURFACE DATA */
.fig {
font-family: var(--font-mono-label);
font-size: 0.625rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-fog-dim);
}
/* Live footnote apparatus: acid superscript on serif statements */
.footnote-sup {
font-family: var(--font-mono);
font-style: normal;
font-size: 0.55em;
vertical-align: super;
color: var(--color-mint);
}
/* Marathon wipe reveal: masks only, never gates data paint */
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
.wipe-in {
animation: wipe-in 0.7s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* Carriage: the acid square that leads a wipe */
@keyframes carriage {
from { transform: translateX(0); opacity: 1; }
85% { opacity: 1; }
to { transform: translateX(min(38vw, 460px)); opacity: 0; }
}
.carriage {
animation: carriage 0.9s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* Tabular numerals for all data */
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
/* Dotted leader rows (label ..... value) */
.leader {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.leader::before {
content: "";
order: 2;
flex: 1;
border-bottom: 1px dotted var(--color-edge-bright);
transform: translateY(-3px);
}
.leader > :first-child { order: 1; }
.leader > :last-child { order: 3; }
/* Light sweep across primary buttons */
.btn-sweep {
position: relative;
overflow: hidden;
}
.btn-sweep::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-130%) skewX(-18deg);
background: linear-gradient(105deg, transparent 42%, rgba(242, 242, 242, 0.5) 50%, transparent 58%);
transition: transform 0.55s ease;
}
.btn-sweep:hover::after {
transform: translateX(130%) skewX(-18deg);
}
/* Neon signage: acid text with a soft tube glow, decorative only */
.neon {
color: var(--color-mint);
text-shadow:
0 0 14px rgba(200, 255, 0, 0.5),
0 0 42px rgba(200, 255, 0, 0.22);
}
.neon-violet {
color: var(--color-volt-soft);
text-shadow:
0 0 14px rgba(82, 0, 255, 0.55),
0 0 42px rgba(82, 0, 255, 0.3);
}
/* Vertical rotated side label, MARATHON style */
.sidelabel {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-mint);
user-select: none;
}

View file

@ -0,0 +1,951 @@
export const hoodquidityVaultsAbi = [
{
"type": "constructor",
"inputs": [
{
"name": "owner_",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "BPS",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint16",
"internalType": "uint16"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "EARLY_EXIT_FEE_BPS",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint16",
"internalType": "uint16"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "PLATFORM_FEE_BPS",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint16",
"internalType": "uint16"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "SWEEP_DELAY",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "campaignCount",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "claim",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "createCampaign",
"inputs": [
{
"name": "stable",
"type": "address",
"internalType": "address"
},
{
"name": "stockToken",
"type": "address",
"internalType": "address"
},
{
"name": "priceFeed",
"type": "address",
"internalType": "address"
},
{
"name": "start",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "end",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "boostWindow",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "boostBps",
"type": "uint16",
"internalType": "uint16"
},
{
"name": "targetDepth",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "rewardBudget",
"type": "uint128",
"internalType": "uint128"
}
],
"outputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "deposit",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "amount",
"type": "uint128",
"internalType": "uint128"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "depositDual",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "stockAmount",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "stableAmount",
"type": "uint128",
"internalType": "uint128"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "getCampaign",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "tuple",
"internalType": "struct HoodquidityVaults.Campaign",
"components": [
{
"name": "stable",
"type": "address",
"internalType": "address"
},
{
"name": "stockToken",
"type": "address",
"internalType": "address"
},
{
"name": "priceFeed",
"type": "address",
"internalType": "address"
},
{
"name": "start",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "end",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "boostWindow",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "boostBps",
"type": "uint16",
"internalType": "uint16"
},
{
"name": "depositsPaused",
"type": "bool",
"internalType": "bool"
},
{
"name": "activeLPs",
"type": "uint32",
"internalType": "uint32"
},
{
"name": "lpCount",
"type": "uint32",
"internalType": "uint32"
},
{
"name": "targetDepth",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "rewardBudget",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "totalEmitted",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "rewardDistributed",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "totalDeposits",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "totalWeighted",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "accRewardPerWeighted",
"type": "uint192",
"internalType": "uint192"
},
{
"name": "lastUpdate",
"type": "uint64",
"internalType": "uint64"
}
]
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "getPosition",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "tuple",
"internalType": "struct HoodquidityVaults.Position",
"components": [
{
"name": "amount",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "weighted",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "claimed",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "stockAmount",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "stockCredit",
"type": "uint128",
"internalType": "uint128"
},
{
"name": "depositedAt",
"type": "uint64",
"internalType": "uint64"
},
{
"name": "lpIndex",
"type": "uint32",
"internalType": "uint32"
},
{
"name": "rewardDebt",
"type": "uint256",
"internalType": "uint256"
}
]
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "owner",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "pause",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "paused",
"inputs": [],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "pendingRewards",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "uint128",
"internalType": "uint128"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "quoteStock",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "stockAmount",
"type": "uint128",
"internalType": "uint128"
}
],
"outputs": [
{
"name": "",
"type": "uint128",
"internalType": "uint128"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "renounceOwnership",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "setDepositsPaused",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "paused_",
"type": "bool",
"internalType": "bool"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "setTreasury",
"inputs": [
{
"name": "treasury_",
"type": "address",
"internalType": "address"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "sweep",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "transferOwnership",
"inputs": [
{
"name": "newOwner",
"type": "address",
"internalType": "address"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "treasury",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "unallocated",
"inputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "uint128",
"internalType": "uint128"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "unpause",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "withdraw",
"inputs": [
{
"name": "id",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "amount",
"type": "uint128",
"internalType": "uint128"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "CampaignCreated",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "stable",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "stockToken",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "start",
"type": "uint64",
"indexed": false,
"internalType": "uint64"
},
{
"name": "end",
"type": "uint64",
"indexed": false,
"internalType": "uint64"
},
{
"name": "targetDepth",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
},
{
"name": "rewardBudget",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Deposited",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
},
{
"name": "weighted",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "DepositedStock",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "stockAmount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
},
{
"name": "stockValue",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "OwnershipTransferred",
"inputs": [
{
"name": "previousOwner",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "newOwner",
"type": "address",
"indexed": true,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Paused",
"inputs": [
{
"name": "account",
"type": "address",
"indexed": false,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "event",
"name": "PlatformFeeTaken",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Redistributed",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "RewardsClaimed",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Swept",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "event",
"name": "TreasuryUpdated",
"inputs": [
{
"name": "treasury",
"type": "address",
"indexed": false,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Unpaused",
"inputs": [
{
"name": "account",
"type": "address",
"indexed": false,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Withdrawn",
"inputs": [
{
"name": "id",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "user",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "amount",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
},
{
"name": "fee",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
},
{
"name": "forfeited",
"type": "uint128",
"indexed": false,
"internalType": "uint128"
}
],
"anonymous": false
},
{
"type": "error",
"name": "BadConfig",
"inputs": []
},
{
"type": "error",
"name": "BadPrice",
"inputs": []
},
{
"type": "error",
"name": "CampaignDepositsPaused",
"inputs": []
},
{
"type": "error",
"name": "CampaignNotActive",
"inputs": []
},
{
"type": "error",
"name": "CampaignNotFound",
"inputs": []
},
{
"type": "error",
"name": "EnforcedPause",
"inputs": []
},
{
"type": "error",
"name": "ExpectedPause",
"inputs": []
},
{
"type": "error",
"name": "NothingToClaim",
"inputs": []
},
{
"type": "error",
"name": "NothingToWithdraw",
"inputs": []
},
{
"type": "error",
"name": "OwnableInvalidOwner",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "OwnableUnauthorizedAccount",
"inputs": [
{
"name": "account",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ReentrancyGuardReentrantCall",
"inputs": []
},
{
"type": "error",
"name": "SafeERC20FailedOperation",
"inputs": [
{
"name": "token",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "TooEarly",
"inputs": []
},
{
"type": "error",
"name": "ZeroAmount",
"inputs": []
}
] as const;

View file

@ -0,0 +1,374 @@
export const mockERC20Abi = [
{
"type": "constructor",
"inputs": [
{
"name": "name_",
"type": "string",
"internalType": "string"
},
{
"name": "symbol_",
"type": "string",
"internalType": "string"
},
{
"name": "decimals_",
"type": "uint8",
"internalType": "uint8"
},
{
"name": "faucetAmount_",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "allowance",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
},
{
"name": "spender",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "approve",
"inputs": [
{
"name": "spender",
"type": "address",
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "balanceOf",
"inputs": [
{
"name": "account",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "decimals",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint8",
"internalType": "uint8"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "faucet",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "faucetAmount",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "mint",
"inputs": [
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "amount",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "name",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "symbol",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "totalSupply",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "transfer",
"inputs": [
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "transferFrom",
"inputs": [
{
"name": "from",
"type": "address",
"internalType": "address"
},
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "Approval",
"inputs": [
{
"name": "owner",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "spender",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"indexed": false,
"internalType": "uint256"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{
"name": "from",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "to",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"indexed": false,
"internalType": "uint256"
}
],
"anonymous": false
},
{
"type": "error",
"name": "ERC20InsufficientAllowance",
"inputs": [
{
"name": "spender",
"type": "address",
"internalType": "address"
},
{
"name": "allowance",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "needed",
"type": "uint256",
"internalType": "uint256"
}
]
},
{
"type": "error",
"name": "ERC20InsufficientBalance",
"inputs": [
{
"name": "sender",
"type": "address",
"internalType": "address"
},
{
"name": "balance",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "needed",
"type": "uint256",
"internalType": "uint256"
}
]
},
{
"type": "error",
"name": "ERC20InvalidApprover",
"inputs": [
{
"name": "approver",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC20InvalidReceiver",
"inputs": [
{
"name": "receiver",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC20InvalidSender",
"inputs": [
{
"name": "sender",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC20InvalidSpender",
"inputs": [
{
"name": "spender",
"type": "address",
"internalType": "address"
}
]
}
] as const;

View file

@ -0,0 +1,113 @@
export const mockV3AggregatorAbi = [
{
"type": "constructor",
"inputs": [
{
"name": "decimals_",
"type": "uint8",
"internalType": "uint8"
},
{
"name": "description_",
"type": "string",
"internalType": "string"
},
{
"name": "initialAnswer",
"type": "int256",
"internalType": "int256"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "decimals",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint8",
"internalType": "uint8"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "description",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "latestRoundData",
"inputs": [],
"outputs": [
{
"name": "roundId",
"type": "uint80",
"internalType": "uint80"
},
{
"name": "answer",
"type": "int256",
"internalType": "int256"
},
{
"name": "startedAt",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "updatedAt",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "answeredInRound",
"type": "uint80",
"internalType": "uint80"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "owner",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "updateAnswer",
"inputs": [
{
"name": "answer",
"type": "int256",
"internalType": "int256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "error",
"name": "NotOwner",
"inputs": []
}
] as const;

30
apps/web/src/lib/chain.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineChain } from "viem";
import { arbitrumSepolia } from "viem/chains";
/**
* Robinhood Chain testnet (Arbitrum Orbit L2). Verified live:
* eth_chainId on the RPC returns 0xb626 = 46630.
* Faucet: https://faucet.testnet.chain.robinhood.com
*/
export const robinhoodTestnet = defineChain({
id: 46_630,
name: "Robinhood Chain Testnet",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: {
default: { http: ["https://rpc.testnet.chain.robinhood.com/rpc"] },
},
blockExplorers: {
default: {
name: "Robinhood Explorer",
url: "https://explorer.testnet.chain.robinhood.com",
},
},
testnet: true,
});
export const deployChain = arbitrumSepolia;
/** Public RPC for live chain stats shown in the UI (block number, gas). */
export const STATS_RPC_URL = "https://1rpc.io/eth";
export const DEXSCREENER_API = "https://api.dexscreener.com";

View file

@ -0,0 +1,5 @@
/** Provided by the manager before release */
export const TWITTER_URL = "https://x.com/hoodquidity";
export const APP_NAME = "Hoodquidity";
export const TAGLINE = "Add liquidity, earn fees, make markets deeper";

View file

@ -0,0 +1,96 @@
import type { Address } from "viem";
export interface MarketMeta {
symbol: string;
name: string;
campaignId: number;
stockToken: Address;
/** Chainlink-style feed pricing the stock token, 8 decimals */
priceFeed: Address;
/** Real reference pair on Dexscreener used for live price/volume */
dexscreenerQuery: string;
risk: "Low" | "Medium" | "High";
}
interface Deployment {
vaults: Address;
usd: Address;
markets: MarketMeta[];
}
/** anvil (local dev) deployment, chainId 31337 */
const local: Deployment = {
vaults: "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
usd: "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
markets: [
{
symbol: "NVDA",
name: "NVIDIA Stock Token",
campaignId: 0,
stockToken: "0x59b670e9fA9D0A427751Af201D676719a970857b",
priceFeed: "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
dexscreenerQuery: "NVDAx",
risk: "Medium",
},
{
symbol: "TSLA",
name: "Tesla Stock Token",
campaignId: 1,
stockToken: "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f",
priceFeed: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319",
dexscreenerQuery: "TSLAx",
risk: "Medium",
},
{
symbol: "AAPL",
name: "Apple Stock Token",
campaignId: 2,
stockToken: "0x09635F643e140090A9A8Dcd712eD6285858ceBef",
priceFeed: "0xc5a5C42992dECbae36851359345FE25997F5C42d",
dexscreenerQuery: "AAPLx",
risk: "Low",
},
{
symbol: "SPY",
name: "SPDR S&P 500 Token",
campaignId: 3,
stockToken: "0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E",
priceFeed: "0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690",
dexscreenerQuery: "SPYx",
risk: "Low",
},
{
symbol: "AIB",
name: "AI Basket Token",
campaignId: 4,
stockToken: "0x9E545E3C0baAB3E08CdfD552C960A1050f373042",
priceFeed: "0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9",
dexscreenerQuery: "basket:NVDAx,AMDx,PLTRx",
risk: "High",
},
],
};
/** Filled in after the public testnet deploy */
const testnet: Deployment | null = null;
const DEPLOYMENTS: Record<number, Deployment | null> = {
31337: local,
421614: testnet, // Arbitrum Sepolia
};
/**
* The chain the app talks to, independent of what the wallet currently
* selects: anvil in dev, the public testnet in the built site.
*/
export const ACTIVE_CHAIN_ID = import.meta.env.DEV ? 31337 : 421614;
export function getActiveDeployment(): Deployment {
return DEPLOYMENTS[ACTIVE_CHAIN_ID] ?? local;
}
export function getDeployment(chainId: number | undefined): Deployment {
return (chainId != null && DEPLOYMENTS[chainId]) || local;
}
export const USD_DECIMALS = 6;

View file

@ -0,0 +1,71 @@
import { DEXSCREENER_API } from "./chain";
export interface PairData {
priceUsd: number;
change24h: number;
volume24h: number;
liquidityUsd: number;
dexId: string;
chainId: string;
pairSymbol: string;
}
interface RawPair {
chainId: string;
dexId: string;
priceUsd?: string;
baseToken: { symbol: string };
quoteToken: { symbol: string };
volume?: { h24?: number };
liquidity?: { usd?: number };
priceChange?: { h24?: number };
}
/**
* Live market data for a tokenized stock, sourced from the deepest
* stable-quoted pair on Dexscreener.
*/
export async function fetchPair(query: string): Promise<PairData | null> {
const res = await fetch(
`${DEXSCREENER_API}/latest/dex/search?q=${encodeURIComponent(query)}`,
);
if (!res.ok) throw new Error(`dexscreener ${res.status}`);
const data = (await res.json()) as { pairs?: RawPair[] };
const pairs = (data.pairs ?? []).filter(
(p) =>
p.baseToken.symbol.toUpperCase() === query.toUpperCase() &&
p.priceUsd != null,
);
if (pairs.length === 0) return null;
const best = pairs.reduce((a, b) =>
(a.liquidity?.usd ?? 0) >= (b.liquidity?.usd ?? 0) ? a : b,
);
return {
priceUsd: Number(best.priceUsd),
change24h: best.priceChange?.h24 ?? 0,
volume24h: best.volume?.h24 ?? 0,
liquidityUsd: best.liquidity?.usd ?? 0,
dexId: best.dexId,
chainId: best.chainId,
pairSymbol: `${best.baseToken.symbol}/${best.quoteToken.symbol}`,
};
}
/** Equal-weight synthetic basket from several underlying tokens. */
export async function fetchBasket(queries: string[]): Promise<PairData | null> {
const parts = (await Promise.all(queries.map((q) => fetchPair(q).catch(() => null)))).filter(
(p): p is PairData => p !== null,
);
if (parts.length === 0) return null;
const n = parts.length;
return {
priceUsd: parts.reduce((s, p) => s + p.priceUsd, 0) / n,
change24h: parts.reduce((s, p) => s + p.change24h, 0) / n,
volume24h: parts.reduce((s, p) => s + p.volume24h, 0),
liquidityUsd: parts.reduce((s, p) => s + p.liquidityUsd, 0),
dexId: "basket",
chainId: "multi",
pairSymbol: "AIB/USDC",
};
}

View file

@ -0,0 +1,36 @@
export function fmtUsd(value: number, compact = true): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: compact && Math.abs(value) >= 10_000 ? "compact" : "standard",
maximumFractionDigits: Math.abs(value) >= 10_000 ? 1 : 2,
}).format(value);
}
export function fmtPct(value: number, digits = 2): string {
return `${(value * 100).toFixed(digits)}%`;
}
export function fmtAprPct(value: number): string {
const pct = value * 100;
if (pct > 9_999) return ">9,999%";
if (pct >= 1_000) return `${Math.round(pct).toLocaleString("en-US")}%`;
return `${pct.toFixed(1)}%`;
}
export function fmtNum(value: number): string {
return new Intl.NumberFormat("en-US", {
notation: Math.abs(value) >= 10_000 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value);
}
export function fmtCountdown(untilSec: number): string {
const s = Math.max(untilSec - Math.floor(Date.now() / 1000), 0);
const d = Math.floor(s / 86_400);
const h = Math.floor((s % 86_400) / 3_600);
const m = Math.floor((s % 3_600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

71
apps/web/src/lib/math.ts Normal file
View file

@ -0,0 +1,71 @@
/**
* Market math shared across screens. Slippage follows the constant-product
* approximation: a buy of size X against a pool with liquidity L (both sides
* combined, USD) moves the price by roughly X / (L/2 + X).
*/
export function slippageFor(tradeUsd: number, liquidityUsd: number): number {
if (liquidityUsd <= 0) return 1;
return tradeUsd / (liquidityUsd / 2 + tradeUsd);
}
/** How a deposit into the campaign vault improves slippage for a given trade. */
export function slippageImpact(
tradeUsd: number,
liquidityUsd: number,
depositUsd: number,
): { before: number; after: number } {
return {
before: slippageFor(tradeUsd, liquidityUsd),
after: slippageFor(tradeUsd, liquidityUsd + depositUsd),
};
}
const POOL_FEE = 0.0025; // 0.25% venue fee accrued by LPs
export function feeApr(volume24h: number, liquidityUsd: number): number {
if (liquidityUsd <= 0) return 0;
return ((volume24h * POOL_FEE) / liquidityUsd) * 365;
}
export function rewardApr(
rewardBudgetUsd: number,
durationDays: number,
tvlUsd: number,
): number {
if (durationDays <= 0) return 0;
const base = Math.max(tvlUsd, 25_000); // floor so an empty vault shows a sane cap
return (rewardBudgetUsd / base) * (365 / durationDays);
}
/**
* Market Health Score, 0 to 100. Blends depth, turnover, slippage for a
* standard $10k trade and campaign progress.
*/
export function healthScore(input: {
liquidityUsd: number;
volume24h: number;
tvlUsd: number;
targetDepthUsd: number;
}): number {
const depth = Math.min(input.liquidityUsd / 2_000_000, 1); // saturates at $2M
const turnover =
input.liquidityUsd > 0
? Math.min(input.volume24h / input.liquidityUsd, 1.5) / 1.5
: 0;
const slip10k = 1 - Math.min(slippageFor(10_000, input.liquidityUsd) / 0.05, 1);
const progress =
input.targetDepthUsd > 0
? Math.min(input.tvlUsd / input.targetDepthUsd, 1)
: 0;
const score =
depth * 0.35 + slip10k * 0.3 + turnover * 0.2 + progress * 0.15;
return Math.round(score * 100);
}
export function healthLabel(score: number): { label: string; tone: "mint" | "violet" | "danger" } {
if (score >= 70) return { label: "Healthy", tone: "mint" };
if (score >= 40) return { label: "Building", tone: "violet" };
return { label: "Needs depth", tone: "danger" };
}

View file

@ -0,0 +1,21 @@
/**
* Entrance animation presets. When the document loads hidden (background tab,
* screenshot bots) requestAnimationFrame is throttled and entrance animations
* would leave content stuck at opacity 0, so they degrade to static rendering.
*/
export const motionEnabled =
typeof document === "undefined" || document.visibilityState === "visible";
const animated = motionEnabled;
export const fadeUp = animated
? { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 } }
: {};
export const inView = animated
? {
initial: { opacity: 0, y: 28 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-60px" },
}
: {};

View file

@ -0,0 +1,22 @@
/**
* Campaign points, computed client-side from onchain position data.
* 10 points per dollar-day, 1.5x for boosted (early window) deposits.
* Points feed future programs; they carry no monetary value.
*/
export const GENESIS_LP_CUTOFF = 100;
export function campaignPoints(opts: {
amountUsd: number;
depositedAt: number;
boosted: boolean;
nowSec?: number;
}): number {
if (opts.amountUsd <= 0 || opts.depositedAt <= 0) return 0;
const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
const days = Math.max(now - opts.depositedAt, 0) / 86_400;
return opts.amountUsd * days * 10 * (opts.boosted ? 1.5 : 1);
}
export function isGenesisLp(lpIndex: number): boolean {
return lpIndex >= 1 && lpIndex <= GENESIS_LP_CUTOFF;
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

50
apps/web/src/lib/wagmi.ts Normal file
View file

@ -0,0 +1,50 @@
import { createAppKit } from "@reown/appkit/react";
import { WagmiAdapter } from "@reown/appkit-adapter-wagmi";
import type { AppKitNetwork } from "@reown/appkit/networks";
import { anvil } from "viem/chains";
import { deployChain, robinhoodTestnet } from "./chain";
// Reown Cloud project id: replace with the real one before release
const projectId = "b56e18d47c72ab683b10814fe9495694";
const networks = (
import.meta.env.DEV
? [anvil, robinhoodTestnet, deployChain]
: [robinhoodTestnet, deployChain]
) as unknown as [AppKitNetwork, ...AppKitNetwork[]];
const wagmiAdapter = new WagmiAdapter({
networks,
projectId,
ssr: false,
});
createAppKit({
adapters: [wagmiAdapter],
networks,
defaultNetwork: (import.meta.env.DEV
? anvil
: robinhoodTestnet) as unknown as AppKitNetwork,
projectId,
metadata: {
name: "Hoodquidity",
description: "Liquidity campaigns for Stock Token markets on Robinhood Chain",
url: typeof window !== "undefined" ? window.location.origin : "https://hoodquidity.xyz",
icons: ["/favicon.ico"],
},
themeMode: "dark",
themeVariables: {
"--w3m-accent": "#c8ff00",
"--w3m-color-mix": "#080808",
"--w3m-color-mix-strength": 30,
"--w3m-border-radius-master": "0px",
"--w3m-font-family": "'JetBrains Mono', ui-monospace, monospace",
},
features: {
analytics: false,
email: false,
socials: false,
},
});
export const wagmiConfig = wagmiAdapter.wagmiConfig;

28
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,28 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import "./index.css";
import App from "./App";
import { wagmiConfig } from "./lib/wagmi";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<BrowserRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
<App />
</BrowserRouter>
</QueryClientProvider>
</WagmiProvider>
</StrictMode>,
);

View file

@ -0,0 +1,589 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { parseUnits } from "viem";
import {
useAccount,
useReadContracts,
useWaitForTransactionReceipt,
useWriteContract,
} from "wagmi";
import { useAppKit } from "@reown/appkit/react";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import { mockERC20Abi } from "@/lib/abi/MockERC20";
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
import { useCampaigns, usePositions, type CampaignState } from "@/hooks/useCampaigns";
import { useAllMarketData } from "@/hooks/useMarketData";
import { feeApr, rewardApr } from "@/lib/math";
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Stat } from "@/components/ui/stat";
import { Skeleton } from "@/components/ui/skeleton";
import { DepthProgress } from "@/components/DepthProgress";
import { SlippageCurve } from "@/components/SlippageCurve";
const RISKS = ["All", "Low", "Medium", "High"] as const;
const CHIP_AMOUNTS = [1_000, 5_000, 10_000];
function statusBadge(c: CampaignState): { label: string; tone: "mint" | "lime" | "fog" } {
if (c.status === "boost") return { label: "Boost 1.5x", tone: "lime" };
if (c.status === "ended") return { label: "Ended", tone: "fog" };
if (c.status === "upcoming") return { label: "Upcoming", tone: "fog" };
return { label: "Active", tone: "mint" };
}
export default function AppBoard() {
const { address, isConnected } = useAccount();
const { open: openConnectModal } = useAppKit();
const deployment = getActiveDeployment();
const { campaigns, isLoading } = useCampaigns();
const { data: pairs } = useAllMarketData(deployment.markets);
const { positions, refetch: refetchPositions } = usePositions();
const [tab, setTab] = useState<"campaigns" | "portfolio">("campaigns");
const [risk, setRisk] = useState<(typeof RISKS)[number]>("All");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [input, setInput] = useState("");
const [toast, setToast] = useState("");
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
const say = (m: string) => {
clearTimeout(toastTimer.current);
setToast(m);
toastTimer.current = setTimeout(() => setToast(""), 3_400);
};
useEffect(() => () => clearTimeout(toastTimer.current), []);
const list = (campaigns ?? []).filter(
(c) => risk === "All" || c.meta.risk === risk,
);
const selected =
list.find((c) => c.meta.campaignId === selectedId) ??
(campaigns ?? []).find((c) => c.meta.campaignId === selectedId) ??
list[0];
const amount = useMemo(() => {
const n = Number(input);
if (!Number.isFinite(n) || n <= 0) return 0n;
try {
return parseUnits(input, USD_DECIMALS);
} catch {
return 0n;
}
}, [input]);
const amountUsd = Number(input) || 0;
const { data: walletData, refetch: refetchWallet } = useReadContracts({
contracts: [
{
address: deployment.usd,
abi: mockERC20Abi,
functionName: "balanceOf",
args: [address ?? "0x0000000000000000000000000000000000000000"],
chainId: ACTIVE_CHAIN_ID,
},
{
address: deployment.usd,
abi: mockERC20Abi,
functionName: "allowance",
args: [address ?? "0x0000000000000000000000000000000000000000", deployment.vaults],
chainId: ACTIVE_CHAIN_ID,
},
],
query: { enabled: !!address },
});
const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n;
const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n;
const balanceUsd = Number(balance) / 10 ** USD_DECIMALS;
const { writeContract, data: txHash, isPending, reset } = useWriteContract({
mutation: { onError: (e) => say(`tx rejected · ${e.name}`) },
});
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
const lastAction = useRef("");
useEffect(() => {
if (!isSuccess) return;
reset();
refetchWallet();
refetchPositions();
if (lastAction.current) say(lastAction.current);
lastAction.current = "";
}, [isSuccess, reset, refetchWallet, refetchPositions]);
const busy = isPending || isConfirming;
const needsFaucet = amount > 0n && balance < amount;
const needsApprove = !needsFaucet && amount > 0n && allowance < amount;
const selPair = selected ? pairs?.[selected.meta.symbol] : undefined;
const selFee = selPair ? feeApr(selPair.volume24h, selPair.liquidityUsd) : 0;
const selReward = selected
? rewardApr(selected.rewardBudgetUsd, selected.durationDays, selected.tvlUsd)
: 0;
const selApr = selFee + selReward;
const act = () => {
if (!selected) return;
if (!isConnected) return openConnectModal?.();
if (amount === 0n) return say("enter an amount to deposit");
if (needsFaucet) {
lastAction.current = "test hUSD minted to wallet";
writeContract({
address: deployment.usd,
abi: mockERC20Abi,
functionName: "faucet",
chainId: ACTIVE_CHAIN_ID,
});
} else if (needsApprove) {
lastAction.current = `approved ${fmtUsd(amountUsd)} hUSD`;
writeContract({
address: deployment.usd,
abi: mockERC20Abi,
functionName: "approve",
args: [deployment.vaults, amount],
chainId: ACTIVE_CHAIN_ID,
});
} else {
lastAction.current = `deposited ${fmtUsd(amountUsd)} -> ${selected.meta.symbol} / hUSD`;
setInput("");
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "deposit",
args: [BigInt(selected.meta.campaignId), amount],
chainId: ACTIVE_CHAIN_ID,
});
}
};
const claim = (campaignId: number) => {
lastAction.current = "rewards claimed -> wallet";
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "claim",
args: [BigInt(campaignId)],
chainId: ACTIVE_CHAIN_ID,
});
};
const withdraw = (campaignId: number, posAmountUsd: number) => {
lastAction.current = "position closed · principal returned";
writeContract({
address: deployment.vaults,
abi: hoodquidityVaultsAbi,
functionName: "withdraw",
args: [BigInt(campaignId), parseUnits(posAmountUsd.toString(), USD_DECIMALS)],
chainId: ACTIVE_CHAIN_ID,
});
};
const openPositions = positions.filter((p) => p.amountUsd > 0);
const totalDeposited = openPositions.reduce((s, p) => s + p.amountUsd, 0);
const totalPending = openPositions.reduce((s, p) => s + p.pendingUsd, 0);
const primaryLabel = !isConnected
? "Connect wallet"
: amount === 0n
? "Enter amount"
: needsFaucet
? "Get test hUSD"
: needsApprove
? "Approve hUSD"
: "Deposit hUSD";
return (
<div className="relative">
<div className="relative mx-auto max-w-7xl px-4 pb-24 pt-10 md:px-8">
{/* desk locator */}
<div className="mb-6 flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim">
<span>Desk 00 // Open campaigns</span>
<span className="hidden items-center gap-2 sm:inline-flex">
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
Live feed
</span>
</div>
{/* tab bar */}
<div className="mb-8 flex items-center gap-6 border-b border-edge">
{(["campaigns", "portfolio"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"-mb-px cursor-pointer border-b-2 bg-transparent px-0.5 pb-3 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors",
tab === t
? "border-mint text-mint"
: "border-transparent text-fog hover:text-snow",
)}
>
{t === "campaigns"
? "Campaigns"
: `Portfolio${openPositions.length ? ` (${openPositions.length})` : ""}`}
</button>
))}
</div>
{tab === "campaigns" && (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px]">
{/* board */}
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="slabel">Campaign board</div>
<h1 className="mt-2 text-3xl md:text-4xl">Open campaigns</h1>
</div>
<div className="flex gap-2">
{RISKS.map((r) => (
<button
key={r}
onClick={() => setRisk(r)}
className={cn(
"cursor-pointer border px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors",
risk === r
? "border-mint bg-mint text-void"
: "border-edge bg-transparent text-fog hover:border-mint hover:text-mint",
)}
>
{r}
</button>
))}
</div>
</div>
{/* ledger: shared column grid, comparison over cards */}
<div className="border border-edge bg-card/60">
<div className="hidden grid-cols-[1.5fr_1.1fr_0.55fr_0.6fr_0.55fr] gap-x-5 border-b border-edge px-5 py-2.5 font-mono text-[9.5px] uppercase tracking-[0.14em] text-fog-dim md:grid">
<span>Market</span>
<span>Depth fill</span>
<span className="text-right">Ends</span>
<span className="text-right">24h vol</span>
<span className="text-right">Est. APR</span>
</div>
{isLoading &&
Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="m-3 h-16" />
))}
{list.map((c) => {
const pair = pairs?.[c.meta.symbol];
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
const apr =
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
const badge = statusBadge(c);
const on = selected?.meta.campaignId === c.meta.campaignId;
return (
<div
key={c.meta.campaignId}
onClick={() => setSelectedId(c.meta.campaignId)}
className={cn(
"relative grid cursor-pointer grid-cols-2 items-center gap-x-5 gap-y-3 border-b border-edge px-5 py-4 transition-colors duration-150 last:border-b-0 md:grid-cols-[1.5fr_1.1fr_0.55fr_0.6fr_0.55fr]",
on ? "bg-card-hover" : "hover:bg-card-hover/60",
)}
>
<span
aria-hidden
className={cn(
"absolute bottom-0 left-0 top-0 w-[2px] bg-mint shadow-[0_0_14px_rgba(200,255,0,0.6)] transition-transform duration-150",
on ? "scale-y-100" : "scale-y-0",
)}
/>
<div className="col-span-2 flex items-center gap-3 md:col-span-1">
<span className="font-display text-base uppercase leading-none">
{c.meta.symbol}{" "}
<span className="font-mono text-[13px] normal-case tracking-normal text-fog-dim">
/ hUSD
</span>
</span>
<span
className={cn(
"font-mono text-[9px] uppercase tracking-[0.12em]",
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
)}
>
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
</span>
{c.status !== "active" && (
<Badge tone={badge.tone} pulse={c.status === "boost"}>
{badge.label}
</Badge>
)}
</div>
<div>
<DepthProgress
progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0}
/>
<div className="num mt-1.5 font-mono text-[10px] tracking-[0.04em] text-fog">
{fmtUsd(depth)}
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
</div>
</div>
<span className="num text-right font-mono text-[11px] text-fog">
{fmtCountdown(c.end)}
</span>
<span className="num text-right font-mono text-[11px] text-fog">
{pair ? fmtUsd(pair.volume24h) : "···"}
</span>
<span className="num text-right text-[15px] font-semibold text-mint">
{fmtAprPct(apr)}
</span>
</div>
);
})}
</div>
<p className="font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
Estimated APR is not guaranteed. Rewards subject to campaign terms.
</p>
</div>
{/* deposit sidebar */}
{selected && (
<div className="flex flex-col gap-4 lg:sticky lg:top-24">
<div
className={cn(
"ticks border border-edge-bright bg-card p-6",
amountUsd > 0 && "ticks-live",
)}
>
<div className="flex items-center justify-between gap-3">
<span className="slabel">Deposit</span>
<Badge
tone={statusBadge(selected).tone}
pulse={selected.status === "boost"}
>
{statusBadge(selected).label}
</Badge>
</div>
<div className="mt-4 font-display text-2xl uppercase">
{selected.meta.symbol}{" "}
<span className="font-mono text-lg normal-case tracking-normal text-fog-dim">
/ hUSD
</span>
</div>
<div className="mt-4 space-y-2 text-[13px]">
<div className="leader">
<span className="text-fog">Fee APR</span>
<span className="num">{fmtAprPct(selFee)}</span>
</div>
<div className="leader">
<span className="text-fog">Reward APR</span>
<span className="num">{fmtAprPct(selReward)}</span>
</div>
<div className="leader">
<span className="text-fog">Boost</span>
<span className="num">
{selected.status === "boost"
? `${(selected.boostBps / 10_000).toFixed(1)}x`
: "none"}
</span>
</div>
<div className="leader">
<span className="text-fog">Est. total APR</span>
<span className="num font-semibold text-mint">{fmtAprPct(selApr)}</span>
</div>
<div className="leader">
<span className="text-fog">Campaign ends</span>
<span className="num">{fmtCountdown(selected.end)}</span>
</div>
</div>
<div className="mt-5 space-y-2.5">
<div className="flex justify-between font-mono text-[10px] uppercase tracking-[0.08em]">
<span className="text-fog">Amount</span>
<button
className="num cursor-pointer text-fog-dim transition-colors hover:text-mint"
onClick={() => setInput(balanceUsd > 0 ? String(balanceUsd) : "")}
>
Balance: {fmtUsd(balanceUsd)}
</button>
</div>
<div className="flex items-center gap-2 border border-edge-bright bg-surface py-1 pl-1 pr-3.5">
<input
value={input}
onChange={(e) => setInput(e.target.value.replace(/[^0-9.]/g, ""))}
placeholder="0.00"
inputMode="decimal"
className="num min-w-0 flex-1 bg-transparent p-3 text-xl text-snow outline-none placeholder:text-fog-dim"
/>
<span className="font-mono text-xs tracking-[0.06em] text-fog">hUSD</span>
</div>
<div className="flex gap-2">
{CHIP_AMOUNTS.map((v) => (
<button
key={v}
onClick={() => setInput(String(v))}
className="num flex-1 cursor-pointer border border-edge bg-transparent py-1.5 font-mono text-[11px] text-fog transition-colors hover:border-mint hover:text-mint"
>
{v.toLocaleString("en-US")}
</button>
))}
<button
onClick={() => setInput(balanceUsd > 0 ? String(balanceUsd) : "")}
className="flex-1 cursor-pointer border border-edge bg-transparent py-1.5 font-mono text-[11px] text-fog transition-colors hover:border-mint hover:text-mint"
>
MAX
</button>
</div>
</div>
<div className="mt-4 space-y-2 border-t border-edge pt-4 text-[13px]">
<div className="leader">
<span className="text-fog">Est. 30d fees</span>
<span className="num">{fmtUsd((amountUsd * selFee) / 12)}</span>
</div>
<div className="leader">
<span className="text-fog">Est. 30d rewards</span>
<span className="num">{fmtUsd((amountUsd * selReward) / 12)}</span>
</div>
<div className="leader">
<span className="text-fog">Est. 30d total</span>
<span className="num font-semibold text-mint">
{fmtUsd((amountUsd * selApr) / 12)}
</span>
</div>
</div>
<Button
className="mt-5 w-full"
size="lg"
disabled={busy || selected.status === "ended"}
onClick={act}
>
{busy ? "Confirming..." : primaryLabel}
</Button>
<p className="mt-3 font-mono text-[9.5px] leading-relaxed tracking-[0.04em] text-fog-dim">
Single-sided deposit. The Depth Vault forms the LP position for you.
Exit any time and keep fees.
</p>
</div>
<div
className={cn(
"ticks border border-edge bg-card p-5",
amountUsd > 0 && "ticks-live",
)}
>
<div className="slabel mb-3">Execution preview</div>
<SlippageCurve
liquidityUsd={(selPair?.liquidityUsd ?? 0) + selected.tvlUsd + amountUsd}
/>
<div className="fig mt-3 border-t border-edge pt-2.5">
Fig. 02 // Slippage by trade size //{" "}
{amountUsd > 0 ? "Includes your deposit" : "Current book"}
</div>
</div>
</div>
)}
</div>
)}
{tab === "portfolio" && (
<div className="flex flex-col gap-6">
<div>
<div className="slabel">Portfolio</div>
<h1 className="mt-2 text-3xl md:text-4xl">Your positions</h1>
</div>
<div
className={cn(
"ticks grid grid-cols-2 gap-7 border border-edge bg-card p-6 md:grid-cols-4",
totalDeposited > 0 && "ticks-live",
)}
>
<Stat label="Total deposited" value={fmtUsd(totalDeposited)} accent />
<Stat label="Claimable rewards" value={fmtUsd(totalPending)} />
<Stat label="Open positions" value={String(openPositions.length)} />
<Stat label="Wallet balance" value={fmtUsd(balanceUsd)} />
</div>
{openPositions.length > 0 ? (
<div className="flex flex-col gap-3.5">
{openPositions.map((p) => {
const c = campaigns?.find(
(x) => x.meta.campaignId === p.meta.campaignId,
);
const pair = pairs?.[p.meta.symbol];
const apr = c
? (pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd)
: 0;
return (
<div
key={p.meta.campaignId}
className="ticks flex flex-wrap items-center justify-between gap-5 border border-edge-bright bg-card px-5 py-5"
>
<div className="flex flex-col gap-1.5">
<span className="font-display text-lg uppercase">
{p.meta.symbol} <span className="font-mono text-[0.75em] font-normal normal-case tracking-normal text-fog-dim">/ hUSD</span>
</span>
<span className="num font-mono text-[10px] tracking-[0.06em] text-fog-dim">
LP receipt hqLP-{p.meta.campaignId}
{c ? ` · ends in ${fmtCountdown(c.end)}` : ""}
{p.boosted ? " · 1.5x boosted" : ""}
</span>
</div>
<div className="flex flex-wrap gap-7 font-mono text-[11px] tracking-[0.05em]">
<span className="flex flex-col gap-1 text-fog">
Deposited
<span className="num text-[15px] text-snow">
{fmtUsd(p.amountUsd)}
</span>
</span>
<span className="flex flex-col gap-1 text-fog">
Est. APR
<span className="num text-[15px] text-snow">{fmtAprPct(apr)}</span>
</span>
<span className="flex flex-col gap-1 text-fog">
Rewards accrued
<span className="num text-[15px] font-semibold text-mint">
{fmtUsd(p.pendingUsd)}
</span>
</span>
</div>
<div className="flex gap-2.5">
<Button
size="sm"
disabled={busy || (c?.status !== "ended" && p.pendingUsd === 0)}
onClick={() => claim(p.meta.campaignId)}
>
Claim
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => withdraw(p.meta.campaignId, p.amountUsd)}
>
Withdraw
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="ticks flex flex-col items-center gap-4 border border-dashed border-edge-bright bg-card px-6 py-16 text-center">
<span className="font-display text-2xl uppercase">No positions yet</span>
<p className="max-w-sm text-[13px] leading-relaxed text-fog">
Pick a campaign, deposit hUSD and your LP receipt will show up here
with live earnings.
</p>
<Button onClick={() => setTab("campaigns")}>Browse campaigns</Button>
</div>
)}
</div>
)}
</div>
{/* toast */}
{toast && (
<div className="fixed bottom-7 left-1/2 z-40 flex -translate-x-1/2 items-center gap-2.5 border border-mint bg-card px-5 py-3 font-mono text-xs tracking-[0.04em] text-snow shadow-[0_0_28px_rgba(200,255,0,0.22)]">
<span className="text-mint">&gt;</span>
<span className="num">{toast}</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,184 @@
import { Link, useParams } from "react-router-dom";
import { useCampaigns, usePosition } from "@/hooks/useCampaigns";
import { useMarketData } from "@/hooks/useMarketData";
import { DepositPanel } from "@/components/DepositPanel";
import { PositionPanel } from "@/components/PositionPanel";
import { DepthProgress } from "@/components/DepthProgress";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Stat } from "@/components/ui/stat";
import { feeApr, rewardApr, slippageFor } from "@/lib/math";
import { fmtUsd, fmtAprPct, fmtPct, fmtCountdown } from "@/lib/format";
export default function CampaignDetail() {
const { id } = useParams();
const campaignId = Number(id);
const { campaigns, isLoading } = useCampaigns();
const campaign = campaigns?.find((c) => c.meta.campaignId === campaignId);
const { data: pair } = useMarketData(
campaign?.meta ?? {
symbol: "",
name: "",
campaignId: 0,
stockToken: "0x0000000000000000000000000000000000000000",
priceFeed: "0x0000000000000000000000000000000000000000",
dexscreenerQuery: "NVDAx",
risk: "Low",
},
);
const { position, refetch } = usePosition(campaignId);
if (isLoading || !campaign) {
return (
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
<Skeleton className="h-10 w-64" />
<div className="mt-8 grid gap-5 lg:grid-cols-3">
<Skeleton className="h-96 lg:col-span-2" />
<Skeleton className="h-96" />
</div>
</div>
);
}
const marketDepth = (pair?.liquidityUsd ?? 0) + campaign.tvlUsd;
const progress = campaign.targetDepthUsd > 0 ? marketDepth / campaign.targetDepthUsd : 0;
const fApr = pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0;
const rApr = rewardApr(campaign.rewardBudgetUsd, campaign.durationDays, campaign.tvlUsd);
return (
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
<div className="mb-6 flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim">
<span>
Desk 00 // Campaign 0{campaign.meta.campaignId} // {campaign.meta.symbol}
</span>
<span className="hidden items-center gap-2 sm:inline-flex">
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
Live feed
</span>
</div>
<Link to="/campaigns" className="font-mono text-xs text-fog transition-colors hover:text-mint">
All campaigns
</Link>
<div className="mt-4 flex flex-wrap items-center gap-4">
<h1 className="text-4xl md:text-6xl">
{campaign.meta.symbol}
<span className="font-mono text-[0.6em] font-normal normal-case tracking-normal text-fog-dim"> / hUSD</span>
</h1>
<Badge
tone={campaign.status === "ended" ? "fog" : campaign.status === "boost" ? "lime" : "mint"}
pulse={campaign.status === "active" || campaign.status === "boost"}
>
{campaign.status === "boost"
? "Boost window open"
: campaign.status === "active"
? "Active"
: campaign.status === "upcoming"
? "Upcoming"
: "Ended"}
</Badge>
<Badge tone={campaign.meta.risk === "Low" ? "mint" : campaign.meta.risk === "Medium" ? "fog" : "danger"}>
{campaign.meta.risk} risk
</Badge>
</div>
<p className="mt-2 max-w-2xl text-fog">
Depth campaign for the {campaign.meta.name} market. Deposits flow into
the Depth Vault and count toward the campaign target.
</p>
<div className="mt-8 grid gap-5 lg:grid-cols-3">
<div className="space-y-5 lg:col-span-2">
<Card className="space-y-5">
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
<Stat
label="Token price"
value={pair ? `$${pair.priceUsd.toFixed(2)}` : "..."}
hint={pair ? `${pair.change24h >= 0 ? "+" : ""}${pair.change24h.toFixed(2)}% 24h` : undefined}
/>
<Stat label="24h volume" value={pair ? fmtUsd(pair.volume24h) : "..."} />
<Stat label="Market depth" value={fmtUsd(marketDepth)} accent />
<Stat
label="Slippage $10k"
value={pair ? fmtPct(slippageFor(10_000, marketDepth)) : "..."}
/>
</div>
<div className="space-y-1.5 border-t border-edge pt-4">
<div className="flex justify-between text-[11px]">
<span className="fig">Fig. 01 // Campaign progress</span>
<span className="num text-fog">
{fmtUsd(marketDepth)} / {fmtUsd(campaign.targetDepthUsd)} target
</span>
</div>
<DepthProgress progress={progress} segments={48} />
</div>
</Card>
<Card>
<div className="slabel mb-4">Campaign terms</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div className="leader">
<span className="text-fog">Fee APR estimate</span>
<span className="num font-semibold">{fmtAprPct(fApr)}</span>
</div>
<div className="leader">
<span className="text-fog">Reward APR estimate</span>
<span className="num font-semibold text-mint">{fmtAprPct(rApr)}</span>
</div>
<div className="leader">
<span className="text-fog">Reward budget</span>
<span className="num font-semibold">{fmtUsd(campaign.rewardBudgetUsd)}</span>
</div>
<div className="leader">
<span className="text-fog">Duration</span>
<span className="num font-semibold">{Math.round(campaign.durationDays)} days</span>
</div>
<div className="leader">
<span className="text-fog">Early boost</span>
<span className="num font-semibold text-lime">
{(campaign.boostBps / 10_000).toFixed(1)}x first {Math.round(campaign.boostWindow / 3_600)}h
</span>
</div>
<div className="leader">
<span className="text-fog">
{campaign.status === "ended" ? "Status" : "Ends in"}
</span>
<span className="num font-semibold">
{campaign.status === "ended" ? "Closed" : fmtCountdown(campaign.end)}
</span>
</div>
<div className="leader">
<span className="text-fog">Exit model</span>
<span className="font-semibold">Hybrid</span>
</div>
<div className="leader">
<span className="text-fog">Deposit assets</span>
<span className="font-semibold">hUSD or dual</span>
</div>
<div className="leader">
<span className="text-fog">Platform fee</span>
<span className="num font-semibold">10% of rewards</span>
</div>
<div className="leader">
<span className="text-fog">Active LPs</span>
<span className="num font-semibold">{campaign.activeLPs}</span>
</div>
</div>
</Card>
</div>
<div className="space-y-5">
<Card>
<div className="slabel mb-4">Depth vault</div>
<DepositPanel campaign={campaign} pair={pair} onSettled={refetch} />
</Card>
{position && position.amountUsd > 0 && (
<Card>
<PositionPanel campaign={campaign} position={position} onSettled={refetch} />
</Card>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,180 @@
import { Link } from "react-router-dom";
import { useCampaigns, type CampaignState } from "@/hooks/useCampaigns";
import { useAllMarketData } from "@/hooks/useMarketData";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { DepthProgress } from "@/components/DepthProgress";
import { feeApr, rewardApr } from "@/lib/math";
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
import { cn } from "@/lib/utils";
function statusLabel(c: CampaignState): { label: string; tone: "mint" | "lime" | "fog" } | null {
if (c.status === "boost") return { label: "Boost 1.5x", tone: "lime" };
if (c.status === "ended") return { label: "Ended", tone: "fog" };
if (c.status === "upcoming") return { label: "Upcoming", tone: "fog" };
return null; // active is the default state, the ledger stays quiet about it
}
export default function Campaigns() {
const { campaigns, isLoading, deployment } = useCampaigns();
const { data: pairs } = useAllMarketData(deployment.markets);
const totalTvl = campaigns?.reduce((s, c) => s + c.tvlUsd, 0) ?? 0;
const totalRewards = campaigns?.reduce((s, c) => s + c.rewardBudgetUsd, 0) ?? 0;
const active = campaigns?.filter((c) => c.status === "active" || c.status === "boost").length ?? 0;
return (
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div>
<div className="slabel">Campaigns</div>
<h1 className="mt-3 text-4xl md:text-6xl">
Markets that need depth
</h1>
<p className="mt-3 max-w-xl text-fog">
Every campaign targets one Stock Token market. Deposit hUSD, receive
an LP receipt and earn a share of rewards while the market gets
deeper.
</p>
</div>
<div className="flex gap-8 border-l border-edge pl-6">
<div>
<div className="num text-2xl font-semibold text-mint">{fmtUsd(totalTvl)}</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Vault TVL</div>
</div>
<div>
<div className="num text-2xl font-semibold">{active}</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Active</div>
</div>
<div>
<div className="num text-2xl font-semibold">{fmtUsd(totalRewards)}</div>
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Rewards pool</div>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-between gap-4 border border-edge bg-surface px-5 py-4">
<p className="text-sm text-fog">
Running a protocol or market making desk, want your market deeper
</p>
<Link to="/partners" className="shrink-0">
<Button variant="outline" size="sm">
Launch a campaign
</Button>
</Link>
</div>
{/* ledger: one row per market, shared columns for comparison */}
<div className="mt-10 border border-edge bg-card/60">
<div className="hidden grid-cols-[1.6fr_0.9fr_1.2fr_0.65fr_0.6fr_0.7fr_0.3fr] gap-x-5 border-b border-edge px-5 py-2.5 font-mono text-[9.5px] uppercase tracking-[0.14em] text-fog-dim lg:grid">
<span>Market</span>
<span className="text-right">Price / 24h</span>
<span>Depth fill</span>
<span className="text-right">Vault TVL</span>
<span className="text-right">Ends</span>
<span className="text-right">Est. APR</span>
<span />
</div>
{isLoading || !campaigns
? Array.from({ length: 5 }, (_, i) => <Skeleton key={i} className="m-3 h-16" />)
: campaigns.map((c, i) => {
const pair = pairs?.[c.meta.symbol];
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
const apr =
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
const status = statusLabel(c);
return (
<Link
key={c.meta.campaignId}
to={`/campaigns/${c.meta.campaignId}`}
className="group relative grid grid-cols-2 items-center gap-x-5 gap-y-3 border-b border-edge px-5 py-5 transition-colors duration-150 last:border-b-0 hover:bg-card-hover lg:grid-cols-[1.6fr_0.9fr_1.2fr_0.65fr_0.6fr_0.7fr_0.3fr]"
>
<span
aria-hidden
className="absolute bottom-0 left-0 top-0 w-[2px] origin-bottom scale-y-0 bg-mint shadow-[0_0_14px_rgba(200,255,0,0.6)] transition-transform duration-200 ease-out group-hover:scale-y-100"
/>
<div className="col-span-2 lg:col-span-1">
<div className="flex items-center gap-3">
<span className="num font-mono text-[10px] text-fog-dim">
0{i + 1}
</span>
<span className="font-display text-lg uppercase leading-none">
{c.meta.symbol}{" "}
<span className="font-mono text-[13px] normal-case tracking-normal text-fog-dim">
/ hUSD
</span>
</span>
<span
className={cn(
"font-mono text-[9px] uppercase tracking-[0.12em]",
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
)}
>
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
</span>
{status && (
<Badge tone={status.tone} pulse={c.status === "boost"}>
{status.label}
</Badge>
)}
</div>
<div className="mt-1 pl-7 text-[11px] text-fog-dim">{c.meta.name}</div>
</div>
<div className="text-right">
<div className="num text-sm text-snow">
{pair ? `$${pair.priceUsd.toFixed(2)}` : "···"}
</div>
{pair && (
<div
className={cn(
"num text-[11px]",
pair.change24h >= 0 ? "text-mint" : "text-danger",
)}
>
{pair.change24h >= 0 ? "+" : ""}
{pair.change24h.toFixed(2)}%
</div>
)}
</div>
<div>
<DepthProgress
progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0}
/>
<div className="num mt-1.5 font-mono text-[10px] tracking-[0.04em] text-fog">
{fmtUsd(depth)}
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
</div>
</div>
<span className="num text-right font-mono text-[12px] text-snow">
{fmtUsd(c.tvlUsd)}
</span>
<span className="num text-right font-mono text-[12px] text-fog">
{c.status === "ended" ? "Closed" : fmtCountdown(c.end)}
</span>
<span className="num text-right text-[15px] font-semibold text-mint">
{fmtAprPct(apr)}
</span>
<span className="hidden text-right font-mono text-xs text-fog-dim transition-colors group-hover:text-mint lg:inline">
</span>
</Link>
);
})}
</div>
<p className="mt-4 font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
Estimated APR is not guaranteed. Rewards subject to campaign terms
</p>
</div>
);
}

View file

@ -0,0 +1,516 @@
import { Link } from "react-router-dom";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { DepthField } from "@/components/backdrop/DepthField";
import { DepthChart } from "@/components/DepthChart";
import { DepthProgress } from "@/components/DepthProgress";
import { CountUp } from "@/components/CountUp";
import { FootnoteMark, FootnoteLine } from "@/components/Footnote";
import { Term } from "@/components/Term";
import { Scramble } from "@/components/fx/Scramble";
import { SideRail } from "@/components/fx/SideRail";
import { useCampaigns } from "@/hooks/useCampaigns";
import { useAllMarketData } from "@/hooks/useMarketData";
import { fmtUsd, fmtAprPct } from "@/lib/format";
import { feeApr, rewardApr } from "@/lib/math";
import { inView, motionEnabled } from "@/lib/motion";
import { cn } from "@/lib/utils";
const HERO_LINES = [
{ text: "Add liquidity", accent: false },
{ text: "Earn fees", accent: true },
{ text: "Make markets deeper", accent: false },
];
const THESIS = [
{
n: "01",
title: "For liquidity providers",
text: "One-asset entry into the markets that pay for depth. Fees and campaign rewards accrue to your share of the vault, exit keeps the fees.",
},
{
n: "02",
title: "For traders",
text: "Deeper books mean tighter spreads and less slippage on size. Execution quality is the product, not a promise.",
},
{
n: "03",
title: "For Robinhood Chain",
text: "Organic depth routed to the pairs that need it, not mercenary TVL chasing emissions. Capital that stays after the incentives.",
},
];
function Folio({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={cn(
"whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim",
className,
)}
>
{children}
</span>
);
}
/** Faint architectural column lines with survey cross marks. */
function ArchGrid() {
return (
<div aria-hidden className="pointer-events-none absolute inset-0 mx-auto max-w-7xl px-4 md:px-8">
<div className="relative h-full">
{[25, 50, 75].map((p) => (
<div key={p}>
<div
className="absolute inset-y-0 hidden w-px bg-edge/50 lg:block"
style={{ left: `${p}%` }}
/>
{[18, 82].map((y) => (
<span
key={y}
className="absolute hidden -translate-x-1/2 -translate-y-1/2 font-mono text-[11px] text-fog-dim/60 lg:block"
style={{ left: `${p}%`, top: `${y}%` }}
>
+
</span>
))}
</div>
))}
</div>
</div>
);
}
export default function Landing() {
const { campaigns, deployment } = useCampaigns();
const { data: pairs } = useAllMarketData(deployment.markets);
const vaultTvl = campaigns?.reduce((s, c) => s + c.tvlUsd, 0) ?? 0;
const marketDepth =
deployment.markets.reduce(
(s, m) => s + (pairs?.[m.symbol]?.liquidityUsd ?? 0),
0,
) + vaultTvl;
const activeCampaigns =
campaigns?.filter((c) => c.status === "active" || c.status === "boost").length ?? 0;
const volume24h = deployment.markets.reduce(
(s, m) => s + (pairs?.[m.symbol]?.volume24h ?? 0),
0,
);
const boardCampaigns = (campaigns ?? []).slice(0, 3);
const STATS = [
{ label: "Tracked market depth", value: marketDepth, accent: true },
{ label: "24h market volume", value: volume24h },
{ label: "Vault TVL", value: vaultTvl },
];
return (
<div className="relative overflow-hidden">
{/* ---------------------------------- hero --------------------------------- */}
<section className="relative border-b border-edge">
<ArchGrid />
<SideRail />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(110% 70% at 50% -10%, rgba(200,255,0,0.07), transparent 60%), radial-gradient(60% 50% at 92% 55%, rgba(82,0,255,0.09), transparent 70%), radial-gradient(45% 40% at 4% 90%, rgba(200,255,0,0.05), transparent 70%)",
}}
/>
{/* folio bar */}
<div className="relative mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 pt-7 md:px-8">
<Folio>Hoodquidity / Depth Desk</Folio>
<Folio className="hidden sm:inline">Robinhood Chain · L2 4663</Folio>
<Folio className="inline-flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
Live · Testnet
</Folio>
</div>
<div className="relative mx-auto max-w-7xl px-4 pt-14 md:px-8 md:pt-20">
<div className="grid gap-10 lg:grid-cols-12">
{/* headline */}
<div className="lg:col-span-8">
<div className="flex items-center gap-4">
<Folio className="text-mint">[ 01 ]</Folio>
<div className="h-px w-24 bg-edge" />
<Folio>
<Scramble text="Liquidity campaigns for Stock Tokens" />
</Folio>
<Folio className="hidden text-fog-dim/70 md:inline">// 流動性</Folio>
</div>
<h1
className="mt-9 leading-[0.94]"
style={{ fontSize: "clamp(2.4rem, 6vw, 5.25rem)" }}
>
{HERO_LINES.map((line, i) => (
<span key={line.text} className="relative block pb-[0.06em]">
<span
className={cn(
"block",
line.accent && "neon text-mint",
motionEnabled && "wipe-in",
)}
style={
motionEnabled
? { animationDelay: `${0.1 + i * 0.14}s` }
: undefined
}
>
{line.text}
</span>
{motionEnabled && (
<span
aria-hidden
className="carriage absolute left-0 top-[0.12em] h-[0.72em] w-[0.5em] bg-mint"
style={{ animationDelay: `${0.1 + i * 0.14}s` }}
/>
)}
</span>
))}
</h1>
</div>
{/* meta column */}
<div className="flex flex-col justify-end gap-7 pb-2 lg:col-span-4">
<motion.p
initial={motionEnabled ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.5 }}
className="certificate max-w-sm text-2xl leading-snug text-snow md:text-[1.7rem]"
>
Depth is the product
<FootnoteMark n={1} live={marketDepth > 0} />
</motion.p>
<motion.p
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.55 }}
className="max-w-sm text-[15px] leading-relaxed text-fog"
>
Pick a market that needs depth, deposit stablecoins and the{" "}
<Term
def="A campaign vault that takes your stablecoin, forms the LP position for the target market and issues a receipt for your share."
risk="Deposits are exposed to market moves. The position may lose value"
>
Depth Vault
</Term>{" "}
builds the position. Fees and rewards accrue to you while the
market becomes tradable.
</motion.p>
<motion.div
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.65 }}
className="flex flex-wrap items-center gap-3"
>
<Link to="/app">
<Button size="lg">Open the desk</Button>
</Link>
<Link
to="/markets"
className="px-1 font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
>
Market depth
</Link>
</motion.div>
</div>
</div>
{/* instrument */}
<motion.figure
initial={motionEnabled ? { opacity: 0, y: 26 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="relative mt-16 border border-edge bg-surface/60 backdrop-blur-sm"
>
<span
aria-hidden
className="pointer-events-none absolute right-6 top-10 select-none text-[9rem] font-semibold leading-none text-snow/[0.04] md:text-[13rem]"
>
</span>
<figcaption className="flex flex-wrap items-baseline justify-between gap-3 border-b border-edge px-5 py-3.5 md:px-7">
<span className="slabel">Cumulative book depth</span>
<Folio className="inline-flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
Live · Both sides
</Folio>
</figcaption>
<div className="h-[300px] md:h-[380px]">
<DepthChart totalDepthUsd={marketDepth} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-edge px-5 py-2.5 md:px-7">
<span className="fig">Fig. 01 // Cumulative depth // Surface data</span>
<FootnoteLine n={1} live={marketDepth > 0}>
<span>
DESK 00 <span className="text-fog-dim">//</span> TRACKED DEPTH{" "}
<span className="text-mint">{fmtUsd(marketDepth)}</span>{" "}
<span className="text-fog-dim">//</span> LIVE FEED
</span>
</FootnoteLine>
</div>
</motion.figure>
{/* stats rail */}
<div className="grid grid-cols-1 gap-x-8 border-t border-edge sm:grid-cols-3">
{STATS.map((s, i) => (
<motion.div
key={s.label}
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.75 + i * 0.08 }}
className={cn(
"flex items-baseline justify-between gap-4 border-b border-edge py-5 sm:border-b-0",
i > 0 && "sm:border-l sm:border-edge sm:pl-8",
)}
>
<Folio>{s.label}</Folio>
<span
className={cn(
"num text-2xl font-semibold md:text-3xl",
s.accent ? "neon-num text-mint" : "text-snow",
)}
>
<CountUp value={s.value} format={fmtUsd} />
</span>
</motion.div>
))}
</div>
{/* survey micro-line */}
<div className="flex items-center justify-between gap-4 pt-3 font-mono text-[9px] uppercase tracking-[0.2em] text-fog-dim/70">
<span> RH-L2 4663 // E-2026.07</span>
<span className="hidden sm:inline"> // Surface data</span>
<span>+ 00 / 05 +</span>
</div>
{/* LIMITS: Robinhood-grade compliance inside the first scroll */}
<div className="mt-10 grid gap-x-8 gap-y-2 border border-edge bg-surface/50 px-5 py-4 md:grid-cols-[auto_1fr] md:px-7">
<span className="fig pt-0.5 text-danger">Limits</span>
<div className="grid gap-x-8 gap-y-1.5 font-mono text-[10px] uppercase tracking-[0.06em] text-fog sm:grid-cols-2">
<span>01 // LP positions may lose value</span>
<span>02 // Trading fees depend on volume</span>
<span>03 // Rewards subject to campaign terms</span>
<span>04 // Availability depends on jurisdiction</span>
</div>
</div>
<div className="pb-20" />
</div>
</section>
{/* ------------------------------- campaigns ------------------------------- */}
<section className="relative border-b border-edge">
<ArchGrid />
<div className="relative mx-auto max-w-7xl px-4 py-24 md:px-8 md:py-28">
<motion.div {...inView} transition={{ duration: 0.5 }} className="flex items-center gap-4">
<Folio className="text-mint">[ 02 ]</Folio>
<Folio className="hidden text-fog-dim/70 sm:inline"></Folio>
<div className="h-px flex-1 bg-edge" />
<Folio>{activeCampaigns} open now</Folio>
</motion.div>
<motion.div
{...inView}
transition={{ duration: 0.5, delay: 0.05 }}
className="mt-9 flex flex-col gap-4 md:flex-row md:items-end md:justify-between"
>
<h2 style={{ fontSize: "clamp(2rem, 4.6vw, 3.5rem)" }} className="leading-[0.95]">
Campaigns collecting depth
</h2>
<Link
to="/campaigns"
className="font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
>
All campaigns
</Link>
</motion.div>
<div className="mt-14 border-t border-edge">
{boardCampaigns.map((c, i) => {
const pair = pairs?.[c.meta.symbol];
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
const apr =
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
return (
<motion.div key={c.meta.campaignId} {...inView} transition={{ duration: 0.5, delay: i * 0.07 }}>
<Link
to={`/campaigns/${c.meta.campaignId}`}
className="group relative grid grid-cols-2 items-center gap-x-6 gap-y-4 border-b border-edge py-7 pl-5 pr-2 transition-colors duration-300 hover:bg-card-hover md:grid-cols-12 md:py-8"
>
<span
aria-hidden
className="absolute bottom-0 left-0 top-0 w-[2px] origin-bottom scale-y-0 bg-mint transition-transform duration-300 ease-out group-hover:scale-y-100"
/>
<div className="col-span-2 flex items-baseline gap-5 md:col-span-4">
<Folio className="text-fog-dim">0{i + 1}</Folio>
<span className="font-display text-2xl uppercase leading-none md:text-3xl">
{c.meta.symbol}
<span className="font-mono text-base normal-case tracking-normal text-fog-dim md:text-lg">
{" "}
/ hUSD
</span>
</span>
</div>
<div className="md:col-span-4">
<div className="flex justify-between pb-2">
<Folio>Depth</Folio>
<span className="num text-[11px] text-fog">
{fmtUsd(depth)}
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
</span>
</div>
<DepthProgress progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0} />
</div>
<div className="md:col-span-2">
<Folio className="mb-1 block">24h volume</Folio>
<span className="num text-sm text-snow">
{pair ? fmtUsd(pair.volume24h) : "···"}
</span>
</div>
<div className="text-right md:col-span-2">
<Folio className="mb-1 block md:text-right">Est. APR</Folio>
<span className="num text-xl font-semibold text-mint md:text-2xl">
{fmtAprPct(apr)}
</span>
</div>
</Link>
</motion.div>
);
})}
</div>
<p className="mt-6 font-mono text-[10px] tracking-[0.05em] text-fog-dim">
Estimated APR is not guaranteed. Rewards subject to campaign terms
</p>
</div>
</section>
{/* --------------------------------- thesis -------------------------------- */}
<section className="relative border-b border-edge">
<ArchGrid />
<div className="relative mx-auto max-w-7xl px-4 py-24 md:px-8 md:py-32">
<div className="grid gap-14 lg:grid-cols-12">
<div className="lg:col-span-5">
<motion.div {...inView} transition={{ duration: 0.5 }} className="flex items-center gap-4">
<Folio className="text-mint">[ 03 ]</Folio>
<div className="h-px w-24 bg-edge" />
<Folio>
<Scramble text="Thesis" interval={11000} />
</Folio>
<Folio className="text-fog-dim/70">// 論点</Folio>
</motion.div>
<motion.h2
{...inView}
transition={{ duration: 0.55, delay: 0.05 }}
className="super mt-9 leading-[0.94] lg:sticky lg:top-28"
style={{ fontSize: "clamp(2rem, 4.2vw, 3.4rem)" }}
>
Thin books
<br />
tax every trade
</motion.h2>
</div>
<div className="lg:col-span-7">
<motion.p
{...inView}
transition={{ duration: 0.5 }}
className="max-w-xl text-[15px] leading-relaxed text-fog"
>
Every shallow market quietly charges traders through slippage
and keeps serious capital out. Hoodquidity turns that gap into
an earning mechanic where the same liquidity works for three
sides at once, and your{" "}
<Term
def="The onchain record of your deposit: your share of the vault, accrued rewards, boost status and exit terms."
risk="Rewards unlock in full only after the campaign ends"
>
LP receipt
</Term>{" "}
tracks exactly what you carry.
</motion.p>
<div className="mt-10">
{THESIS.map((t, i) => (
<motion.div
key={t.n}
{...inView}
transition={{ duration: 0.5, delay: i * 0.08 }}
className="grid grid-cols-[3rem_1fr] gap-6 border-t border-edge py-8 last:border-b"
>
<span className="num pt-1 font-mono text-sm text-fog-dim">{t.n}</span>
<div>
<h3 className="text-xl font-bold leading-tight md:text-[1.35rem]">
{t.title}
</h3>
<p className="mt-3 max-w-lg text-sm leading-relaxed text-fog">{t.text}</p>
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
</section>
{/* ---------------------------------- cta ---------------------------------- */}
<section className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 opacity-25">
<DepthField flip />
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(80% 110% at 50% 120%, rgba(200,255,0,0.10), transparent 60%), radial-gradient(40% 55% at 88% 10%, rgba(82,0,255,0.08), transparent 70%)",
}}
/>
<span
aria-hidden
className="neon-violet pointer-events-none absolute right-8 top-14 hidden select-none text-lg tracking-[0.4em] lg:block"
style={{ writingMode: "vertical-rl" }}
>
</span>
<div className="relative mx-auto max-w-7xl px-4 py-32 md:px-8 md:py-40">
<Folio className="text-mint">
<Scramble text="Be the liquidity behind tokenized markets" interval={12000} />
</Folio>
<motion.h2
{...inView}
transition={{ duration: 0.6 }}
className="super mt-7 max-w-5xl leading-[0.9]"
style={{ fontSize: "clamp(2.5rem, 6.5vw, 5.25rem)" }}
>
Provide depth
<br />
<span className="text-mint">Get paid for it</span>
</motion.h2>
<motion.div
{...inView}
transition={{ duration: 0.5, delay: 0.12 }}
className="mt-11 flex flex-wrap items-center gap-5"
>
<Link to="/app">
<Button size="lg">Open the desk</Button>
</Link>
<Link
to="/campaigns"
className="font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
>
Browse campaigns
</Link>
</motion.div>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,153 @@
import { Link } from "react-router-dom";
import { useCampaigns } from "@/hooks/useCampaigns";
import { useAllMarketData } from "@/hooks/useMarketData";
import { useChainStats } from "@/hooks/useChainStats";
import { Skeleton } from "@/components/ui/skeleton";
import { HealthRadial } from "@/components/HealthRadial";
import { SlippageCurve } from "@/components/SlippageCurve";
import { DepthHistory } from "@/components/DepthHistory";
import { rewardsRemainingUsd } from "@/hooks/useCampaigns";
import { healthScore } from "@/lib/math";
import { fmtUsd, fmtNum } from "@/lib/format";
import { cn } from "@/lib/utils";
export default function Markets() {
const { campaigns, isLoading, deployment } = useCampaigns();
const { data: pairs } = useAllMarketData(deployment.markets);
const { data: stats } = useChainStats();
return (
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<div className="slabel">Markets</div>
<h1 className="mt-3 text-4xl md:text-6xl">Market depth dashboard</h1>
<p className="mt-3 max-w-xl text-fog">
Live depth, volume and slippage across supported Stock Token
markets, sourced from public market data.
</p>
</div>
{stats && (
<div className="num flex shrink-0 items-center gap-2 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.14em] text-fog-dim">
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
L1 {fmtNum(stats.blockNumber)} // Gas {stats.gasGwei.toFixed(2)} gwei
</div>
)}
</div>
{/* quiet terminal: one frame, hairline sections, no boxes */}
<div className="mt-10 border border-edge bg-card/60">
{isLoading || !campaigns
? Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="m-3 h-44" />)
: campaigns.map((c, i) => {
const pair = pairs?.[c.meta.symbol];
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
const score = healthScore({
liquidityUsd: depth,
volume24h: pair?.volume24h ?? 0,
tvlUsd: c.tvlUsd,
targetDepthUsd: c.targetDepthUsd,
});
return (
<section
key={c.meta.campaignId}
className="border-b border-edge last:border-b-0"
>
{/* market strip */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-edge/60 px-5 py-3 md:px-7">
<div className="flex items-baseline gap-3">
<span className="num font-mono text-[10px] text-fog-dim">
0{i + 1}
</span>
<span className="font-display text-xl uppercase leading-none">
{c.meta.symbol}{" "}
<span className="font-mono text-[14px] normal-case tracking-normal text-fog-dim">
/ hUSD
</span>
</span>
<span className="hidden text-[11px] text-fog-dim sm:inline">
{c.meta.name}
</span>
<span
className={cn(
"font-mono text-[9px] uppercase tracking-[0.12em]",
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
)}
>
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
</span>
</div>
<Link
to={`/campaigns/${c.meta.campaignId}`}
className="font-mono text-[11px] uppercase tracking-[0.1em] text-fog transition-colors hover:text-mint"
>
Deepen this market
</Link>
</div>
<div className="grid gap-6 px-5 py-5 md:grid-cols-[1fr_1.25fr_auto] md:px-7">
{pair ? (
<div className="space-y-2 text-sm">
<div className="leader">
<span className="text-fog">Price</span>
<span className="num font-semibold">${pair.priceUsd.toFixed(2)}</span>
</div>
<div className="leader">
<span className="text-fog">24h change</span>
<span className={cn("num font-semibold", pair.change24h >= 0 ? "text-mint" : "text-danger")}>
{pair.change24h >= 0 ? "+" : ""}
{pair.change24h.toFixed(2)}%
</span>
</div>
<div className="leader">
<span className="text-fog">24h volume</span>
<span className="num font-semibold">{fmtUsd(pair.volume24h)}</span>
</div>
<div className="leader">
<span className="text-fog">Total depth</span>
<span className="num font-semibold text-mint">{fmtUsd(depth)}</span>
</div>
<div className="leader">
<span className="text-fog">Active LPs</span>
<span className="num font-semibold">{c.activeLPs}</span>
</div>
<div className="leader">
<span className="text-fog">Rewards remaining</span>
<span className="num font-semibold text-lime">
{fmtUsd(rewardsRemainingUsd(c, Math.floor(Date.now() / 1000)))}
</span>
</div>
</div>
) : (
<Skeleton className="h-32 w-full" />
)}
<div className="space-y-3 md:border-l md:border-edge md:pl-6">
<div className="fig">Fig. 01 // Slippage by trade size</div>
<SlippageCurve liquidityUsd={depth} />
<div className="pt-1.5">
<div className="fig">Fig. 02 // Depth 30d // Estimated</div>
<DepthHistory currentUsd={depth} seedKey={c.meta.symbol} />
</div>
</div>
<div className="flex flex-row items-center gap-4 md:flex-col md:items-end md:justify-start md:border-l md:border-edge md:pl-6">
<HealthRadial score={score} />
<span className="fig md:text-right">
Fig. 03
<br className="hidden md:block" /> Health
</span>
</div>
</div>
</section>
);
})}
</div>
<p className="mt-4 font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
Depth history is estimated. Slippage figures derive from live pool
liquidity and are not execution quotes
</p>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more