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

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