diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index c19b7653..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"5394fd24-2c0e-4283-be73-5c2f3db6eb97","pid":28223,"acquiredAt":1783688629894} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b925adae..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__openchainbench__list_benchmarks" - ] - } -} diff --git a/.gitignore b/.gitignore index c9d710d4..9aabd4cf 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ scripts/hf_publisher/.venv/ scripts/hf_space/.venv/ **/__pycache__/ *.pyc + +# Claude harness local state (per-session lock + per-user perms) +.claude/settings.local.json +.claude/*.lock diff --git a/src/components/back-link.tsx b/src/components/back-link.tsx deleted file mode 100644 index 92a7b63a..00000000 --- a/src/components/back-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; - -/** "Back to
" link used as the lead-in row on every detail - * page. The pattern (ArrowLeft + small muted label) was repeated - * verbatim across six route files; consolidating here means the - * visual treatment of "back nav" lives in one place. */ -interface BackLinkProps { - href: string; - label: string; - className?: string; -} - -const BASE_CLASS = - "inline-flex items-center gap-1.5 text-sm text-ink-muted hover:text-ink"; - -export function BackLink({ href, label, className }: BackLinkProps) { - const cls = className ? `${BASE_CLASS} ${className}` : BASE_CLASS; - return ( - - - {label} - - ); -} diff --git a/src/components/hl-frontend-grid.tsx b/src/components/hl-frontend-grid.tsx deleted file mode 100644 index 9a88d287..00000000 --- a/src/components/hl-frontend-grid.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import type { - HlHistoryFrontendCompact, - HlHistorySummary, -} from "@/lib/hl-builder-stats"; -import { HlFrontendCard } from "@/components/hl-frontend-card"; - -/** - * Responsive grid of `HlFrontendCard`, one per frontend in the compact - * history blob. Client component so the sort selector is interactive - * without a network round-trip. - * - * Sort modes: - * - `fees` → last non-null fees value descending (default: matches - * the leaderboard's implicit ordering). - * - `peak` → all-time max of the fees array, descending. - * - `age` → first-active timestamp ascending (oldest first). - * - `volume`→ last non-null volume value descending. - */ - -type SortBy = "fees" | "volume" | "peak" | "age"; - -export function HlFrontendGrid({ - history, - sortBy: initialSortBy = "fees", -}: { - history: HlHistorySummary; - sortBy?: SortBy; -}) { - const [sortBy, setSortBy] = useState(initialSortBy); - - const sorted = useMemo( - () => sortFrontends(history.frontends, sortBy), - [history.frontends, sortBy], - ); - - return ( -
-
-

- {history.frontends.length} frontends · 12-month rolling 30d fees -

-
- - Fees now - - - Volume now - - - All-time peak - - - Oldest first - -
-
- -
- {sorted.map((f, i) => ( - - ))} -
-
- ); -} - -function SortButton({ - value, - current, - onSelect, - children, -}: { - value: SortBy; - current: SortBy; - onSelect: (v: SortBy) => void; - children: React.ReactNode; -}) { - const active = value === current; - return ( - - ); -} - -function lastNonNull(arr: (number | null)[]): number { - for (let i = arr.length - 1; i >= 0; i--) { - const v = arr[i]; - if (v !== null && Number.isFinite(v)) return v; - } - return 0; -} - -function peakOf(arr: (number | null)[]): number { - let m = 0; - for (const v of arr) { - if (v !== null && v > m) m = v; - } - return m; -} - -function sortFrontends( - frontends: HlHistoryFrontendCompact[], - by: SortBy, -): HlHistoryFrontendCompact[] { - const copy = [...frontends]; - if (by === "fees") { - copy.sort((a, b) => lastNonNull(b.fees) - lastNonNull(a.fees)); - } else if (by === "volume") { - copy.sort((a, b) => lastNonNull(b.volume) - lastNonNull(a.volume)); - } else if (by === "peak") { - copy.sort((a, b) => peakOf(b.fees) - peakOf(a.fees)); - } else if (by === "age") { - copy.sort((a, b) => a.firstIdx - b.firstIdx); - } - return copy; -} diff --git a/src/components/hl-history-chart.tsx b/src/components/hl-history-chart.tsx deleted file mode 100644 index 5186c465..00000000 --- a/src/components/hl-history-chart.tsx +++ /dev/null @@ -1,598 +0,0 @@ -"use client"; - -import { useMemo, useRef, useState } from "react"; -import type { - HlHistoryFrontendCompact, - HlHistorySummary, -} from "@/lib/hl-builder-stats"; - -/** - * 12-month evolution chart for every active HL frontend (~98). Two - * toggleable metrics (fees / volume, both 30d rolling). Top-N frontends - * are drawn in the OCB palette; the remaining "long tail" renders in a - * desaturated grey overlay so the eye still gets the shape of the - * cohort's overall scale without the legend blowing up. - * - * The input blob is the compact shape written by the worker: - * - shared time axis `t0 + step*i` - * - per-frontend `firstIdx` drops leading nulls - * - values are pre-rounded to integer USD - * - * Design goals: - * - Stays a single SVG. No recharts / D3. 98 × 365 int points renders - * comfortably; grey tail lines share a single `` styling. - * - Gaps: `v === null` points break the line rather than dropping to - * zero. Matches the harness' "no sample this UTC day" semantic and - * keeps early-history cohorts (post-launch) from starting from an - * artificial floor. - * - Colours: 10-slot OCB palette, cycled if the top set grows past - * 10. Hovered / pinned line lifts to full opacity; the rest dim. - * - Crosshair tooltip lists top-N + hovered tail entry so the reader - * never chases a grey line without a label. - */ - -const COLORS = [ - "#9d65ff", // violet - "#ff8a3d", // orange - "#22c55e", // emerald - "#38bdf8", // sky - "#f43f5e", // rose - "#eab308", // amber - "#14b8a6", // teal - "#a855f7", // fuchsia - "#f97316", // deep orange - "#0ea5e9", // blue - "#84cc16", // lime - "#ec4899", // pink - "#06b6d4", // cyan - "#f59e0b", // dark amber - "#10b981", // green - "#8b5cf6", // purple - "#ef4444", // red - "#3b82f6", // indigo - "#d946ef", // magenta - "#65a30d", // olive -]; - -/** How many frontends get a colour + legend entry. The rest are drawn - * as a desaturated grey overlay so the chart shows the full cohort's - * scale without the legend collapsing under 98 chips. */ -const TOP_COLORED = 20; - -type Metric = "fees" | "volume"; - -export function HlHistoryChart({ - history, - focusSlugs, -}: { - history: HlHistorySummary; - /** When provided, only these slugs render (all in colour, no grey - * long-tail overlay, no top/tail split). Powers the per-frontend - * detail page at `/hyperliquid/[slug]`. */ - focusSlugs?: string[]; -}) { - const [metric, setMetric] = useState("fees"); - const [pinnedSlug, setPinnedSlug] = useState(null); - - const focusSet = useMemo( - () => (focusSlugs && focusSlugs.length > 0 ? new Set(focusSlugs) : null), - [focusSlugs], - ); - - const activeFrontends = useMemo( - () => - focusSet - ? history.frontends.filter((f) => focusSet.has(f.slug)) - : history.frontends, - [history.frontends, focusSet], - ); - if (activeFrontends.length === 0) { - return ( -

- No history samples yet — the backfill is still populating. -

- ); - } - - // Focus mode: every requested slug gets a colour; skip the grey tail. - const topFrontends = focusSet - ? activeFrontends - : activeFrontends.slice(0, TOP_COLORED); - const tailFrontends = focusSet ? [] : activeFrontends.slice(TOP_COLORED); - - return ( -
-
-
-

- Last 12 months · rolling 30d -

-

- Daily-stepped snapshot of {activeFrontends.length} HL frontends - {tailFrontends.length > 0 ? ( - <> - {" "} - — top {TOP_COLORED} highlighted, {tailFrontends.length} in the - grey long tail - - ) : null} -

-
-
- - log10 scale - -
- - -
-
-
- - - -
- {topFrontends.map((f, i) => { - const color = COLORS[i % COLORS.length]; - const pinned = pinnedSlug === f.slug; - return ( - - ); - })} -
-
- ); -} - -function ChartCanvas({ - history, - topFrontends, - tailFrontends, - metric, - pinnedSlug, -}: { - history: HlHistorySummary; - topFrontends: HlHistoryFrontendCompact[]; - tailFrontends: HlHistoryFrontendCompact[]; - metric: Metric; - pinnedSlug: string | null; -}) { - const W = 1100; - const H = 360; - const PAD_L = 68; - const PAD_R = 20; - const PAD_T = 20; - const PAD_B = 44; - const plotW = W - PAD_L - PAD_R; - const plotH = H - PAD_T - PAD_B; - - const stepMs = history.step * 1000; - const t0 = history.t0; - - const seriesOf = (f: HlHistoryFrontendCompact): (number | null)[] => - metric === "fees" ? f.fees : f.volume; - - const timestampAt = (f: HlHistoryFrontendCompact, i: number): number => - t0 + stepMs * (f.firstIdx + i); - - // Shared time axis: derive from the compact envelope. Longest series = - // t0 → t0 + step*(maxFirstIdx + maxLen - 1). Fall back to (t0, t0+step) - // so the SVG still lays out on an empty payload. - const tRange = useMemo(() => { - let tMin = Number.POSITIVE_INFINITY; - let tMax = Number.NEGATIVE_INFINITY; - const all = [...topFrontends, ...tailFrontends]; - for (const f of all) { - const s = seriesOf(f); - if (s.length === 0) continue; - const first = timestampAt(f, 0); - const last = timestampAt(f, s.length - 1); - if (first < tMin) tMin = first; - if (last > tMax) tMax = last; - } - if (!Number.isFinite(tMin) || !Number.isFinite(tMax) || tMin === tMax) { - return { tMin: t0, tMax: t0 + stepMs }; - } - return { tMin, tMax }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [topFrontends, tailFrontends, metric, t0, stepMs]); - - const yMax = useMemo(() => { - let m = 0; - const all = [...topFrontends, ...tailFrontends]; - for (const f of all) { - for (const v of seriesOf(f)) { - if (v !== null && v > m) m = v; - } - } - return niceLogMax(m); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [topFrontends, tailFrontends, metric]); - - // Log10 scale on the Y axis. We compress the value into log space via - // log10(v + 1) so v === 0 maps cleanly to 0 (no −∞) and the +1 offset - // is negligible once we hit even $10. Effective floor is 1 (log10(1+1) - // ≈ 0.30), which keeps sub-$1 noise off the axis. Long-tail frontends - // in the $100–$10k range now get vertical breathing room next to the - // $1M+ leaders instead of collapsing into the zero line. - const logMin = 0; // log10(0 + 1) = 0 - const logMax = Math.log10(yMax + 1); - const logDen = logMax - logMin || 1; - - const xFor = (t: number) => { - const span = tRange.tMax - tRange.tMin || 1; - return PAD_L + ((t - tRange.tMin) / span) * plotW; - }; - const yFor = (v: number) => { - const clamped = v > 0 ? v : 0; - const norm = (Math.log10(clamped + 1) - logMin) / logDen; - return PAD_T + plotH * (1 - norm); - }; - - // Multi-segment path: break the line whenever we hit a null so the - // chart shows gaps rather than a straight fall to zero + spike back. - const pathFor = (f: HlHistoryFrontendCompact): string => { - const s = seriesOf(f); - const parts: string[] = []; - let inSegment = false; - for (let i = 0; i < s.length; i++) { - const v = s[i]; - if (v === null) { - inSegment = false; - continue; - } - const cmd = inSegment ? "L" : "M"; - const t = timestampAt(f, i); - parts.push(`${cmd} ${xFor(t).toFixed(1)} ${yFor(v).toFixed(1)}`); - inSegment = true; - } - return parts.join(" "); - }; - - // Power-of-10 gridlines from $1 → yMax. Log axis needs decade ticks - // (not evenly spaced fractions) so the reader can eyeball orders of - // magnitude directly. - const yTicks = useMemo(() => buildLogTicks(yMax), [yMax]); - const monthTicks = useMemo( - () => buildMonthTicks(tRange.tMin, tRange.tMax), - [tRange.tMin, tRange.tMax], - ); - - const svgRef = useRef(null); - const [hoverT, setHoverT] = useState(null); - - const onMove: React.PointerEventHandler = (e) => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const xRatio = (e.clientX - rect.left) / rect.width; - const px = xRatio * W; - if (px < PAD_L || px > W - PAD_R) { - setHoverT(null); - return; - } - const span = tRange.tMax - tRange.tMin; - const t = tRange.tMin + ((px - PAD_L) / plotW) * span; - setHoverT(t); - }; - - // Snap hover to nearest sample per frontend for the tooltip readout. - // Only the coloured top-N surface in the tooltip; a 98-line list would - // be unreadable. - const hoverRows = useMemo(() => { - if (hoverT === null) return null; - const rows: { slug: string; name: string; color: string; v: number | null }[] = []; - for (let i = 0; i < topFrontends.length; i++) { - const f = topFrontends[i]; - const s = seriesOf(f); - if (s.length === 0) { - rows.push({ slug: f.slug, name: f.name, color: COLORS[i % COLORS.length], v: null }); - continue; - } - let bestIdx = 0; - let bestDist = Math.abs(timestampAt(f, 0) - hoverT); - for (let j = 1; j < s.length; j++) { - const d = Math.abs(timestampAt(f, j) - hoverT); - if (d < bestDist) { - bestDist = d; - bestIdx = j; - } - } - rows.push({ - slug: f.slug, - name: f.name, - color: COLORS[i % COLORS.length], - v: s[bestIdx] ?? null, - }); - } - rows.sort((a, b) => (b.v ?? -1) - (a.v ?? -1)); - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hoverT, topFrontends, metric, t0, stepMs]); - - const hoverX = hoverT !== null ? xFor(hoverT) : null; - const hoverDate = hoverT !== null ? formatDate(hoverT) : null; - - return ( -
- setHoverT(null)} - > - {yTicks.map((v) => { - const y = yFor(v); - const isFloor = v <= 1; - return ( - - - - {fmtUSDShort(v)} - - - ); - })} - - {monthTicks.map((mt) => { - const x = xFor(mt.t); - if (x < PAD_L - 1 || x > W - PAD_R + 1) return null; - return ( - - - - {mt.label} - - - ); - })} - - {/* Long-tail grey overlay. Drawn first so the coloured top-N - paints above it. Kept as one class + one stroke so the DOM - stays cheap even with ~80 extra paths. */} - {tailFrontends.map((f) => ( - - ))} - - {topFrontends.map((f, i) => { - const color = COLORS[i % COLORS.length]; - const dimmed = pinnedSlug !== null && pinnedSlug !== f.slug; - return ( - - ); - })} - - {hoverX !== null && ( - - )} - - - {hoverRows && hoverDate && hoverX !== null && ( - - )} -
- ); -} - -function Tooltip({ - rows, - date, - xFrac, -}: { - rows: { slug: string; name: string; color: string; v: number | null }[]; - date: string; - xFrac: number; -}) { - const left = Math.max(4, Math.min(96, xFrac * 100)); - const flipX = left > 55; - return ( -
-

- {date} -

-
- {rows.slice(0, 10).map((r) => ( -
- - - {r.name} - - - {r.v === null ? "—" : fmtUSDShort(r.v)} - -
- ))} -
-
- ); -} - -/** Round up to the next decade for a log-scale ceiling ($10, $100, $1k, …). - * Guarantees the topmost gridline is a clean power of 10 so labels never - * read `$1.7M` or `$3.4M`. */ -function niceLogMax(v: number): number { - if (!Number.isFinite(v) || v <= 10) return 10; - return Math.pow(10, Math.ceil(Math.log10(v))); -} - -/** Decade gridlines from $1 up through niceLogMax. Small enough (≤ 8 - * entries for a $10M ceiling) that we don't need mid-decade ticks. */ -function buildLogTicks(max: number): number[] { - const topExp = Math.max(1, Math.ceil(Math.log10(Math.max(max, 10)))); - const out: number[] = [1]; - for (let e = 1; e <= topExp; e++) { - out.push(Math.pow(10, e)); - } - return out; -} - -function fmtUSDShort(v: number): string { - if (!Number.isFinite(v) || v === 0) return "$0"; - const abs = Math.abs(v); - if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(1)}B`; - if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; - if (abs >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; - return `$${v.toFixed(0)}`; -} - -function formatDate(t: number): string { - const d = new Date(t); - return d.toISOString().slice(0, 10); -} - -/** First-of-month labels between two epoch-ms bounds. Keeps the count - * bounded (~12 labels) so the axis never crowds. */ -function buildMonthTicks( - tMinMs: number, - tMaxMs: number, -): { t: number; label: string }[] { - const out: { t: number; label: string }[] = []; - const start = new Date(tMinMs); - const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); - const MONTH_NAMES = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - while (cursor.getTime() <= tMaxMs) { - const t = cursor.getTime(); - if (t >= tMinMs) { - const label = - cursor.getUTCMonth() === 0 - ? `${MONTH_NAMES[0]} ${cursor.getUTCFullYear() % 100}` - : MONTH_NAMES[cursor.getUTCMonth()]; - out.push({ t, label }); - } - cursor.setUTCMonth(cursor.getUTCMonth() + 1); - } - return out; -} diff --git a/src/components/logo-card-link.tsx b/src/components/logo-card-link.tsx deleted file mode 100644 index d5b4f8c4..00000000 --- a/src/components/logo-card-link.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Link from "next/link"; -import type { ReactNode } from "react"; - -/** - * Shared primitive for the "card-soft" link tiles that paint the hub - * pages (alternatives, compare, chains, answers top-results, etc.). - * - * The hub cards all rendered the same skeleton inline - logo on the left, - * title + subtitle in the middle, a chevron or pill on the right. Pulling - * the markup here keeps the hover state, padding ramp and shape contract - * (`card-soft rounded-xl ... hover:border-ink/40 transition-colors`) in - * one place so a future restyle doesn't require chasing 8 call sites. - * - * Three layouts: - * - `variant="default"` (row, p-4 sm:p-5): roomy hub tile. - * - `variant="compact"` (row, p-4): tighter hub-index shape used by - * /alternatives, /compare, /chains. - * - `variant="stack"` (column, p-4): the stat-card shape used by the - * "Top N" sections in /answers/[slug] and /alternatives/[slug]. Top - * row stays the same; the bottom row is whatever `children` provides - * (typically a metric value strip). - * - * `subtitle` is a passthrough ReactNode. Pass a plain string to get the - * default paragraph styling, or pass a fully-styled element (e.g. the - * `label-mono` caption that compare/chains used inline) to opt out. - */ -interface LogoCardLinkProps { - href: string; - title: ReactNode; - subtitle?: ReactNode; - /** Logo, chain icon, emoji, dual-logo stack - anything that paints on the left. */ - logo?: ReactNode; - /** Right-side affordance: chevron, pill, type badge, status dot. */ - rightSlot?: ReactNode; - /** Extra content rendered below the top row (only painted when variant="stack"). */ - children?: ReactNode; - className?: string; - variant?: "default" | "compact" | "stack"; -} - -export function LogoCardLink({ - href, - title, - subtitle, - logo, - rightSlot, - children, - className, - variant = "default", -}: LogoCardLinkProps) { - const padding = variant === "default" ? "p-4 sm:p-5" : "p-4"; - const layout = - variant === "stack" - ? "flex flex-col gap-2" - : "flex items-center gap-4"; - - const base = `card-soft rounded-xl ${padding} h-full transition-colors hover:border-ink/40 group ${layout}`; - const merged = className ? `${base} ${className}` : base; - - // String subtitles get the default paragraph styling so the common - // /alternatives case stays a one-liner. Non-string subtitles render - // as-is so /compare and /chains can keep their uppercase mono caption. - const renderSubtitle = () => { - if (subtitle == null) return null; - if (typeof subtitle === "string") { - return ( -

- {subtitle} -

- ); - } - return subtitle; - }; - - if (variant === "stack") { - return ( - -
- {logo &&
{logo}
} -
-

- {title} -

- {subtitle && ( -

- {subtitle} -

- )} -
- {rightSlot &&
{rightSlot}
} -
- {children} - - ); - } - - return ( - - {logo &&
{logo}
} -
-

- {title} -

- {renderSubtitle()} -
- {rightSlot &&
{rightSlot}
} - - ); -} diff --git a/src/components/report-section-modal.tsx b/src/components/report-section-modal.tsx deleted file mode 100644 index 9901dc82..00000000 --- a/src/components/report-section-modal.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { CheckCircle2, Loader2, X } from "lucide-react"; - -type Status = "idle" | "submitting" | "ok" | "error"; - -type Props = { - slug: string; - onClose: () => void; -}; - -/** - * The heavy report-form modal. Code-split out of the trigger via - * `next/dynamic` so the form + icons + status state only land in the - * client bundle when the user actually clicks "Report". - */ -export default function ReportSectionModal({ slug, onClose }: Props) { - const [message, setMessage] = useState(""); - const [contact, setContact] = useState(""); - const [wantsContact, setWantsContact] = useState(false); - const [status, setStatus] = useState("idle"); - const [errorMsg, setErrorMsg] = useState(null); - - // Lock body scroll while the modal is mounted and close on Escape. - // Effect lives in the modal (not the trigger) because the modal only - // mounts when `open` is true, so we don't even need to gate on it. - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => { - document.body.style.overflow = prev; - window.removeEventListener("keydown", onKey); - }; - }, [onClose]); - - async function submit(e: React.SyntheticEvent) { - e.preventDefault(); - if (status === "submitting") return; - setStatus("submitting"); - setErrorMsg(null); - try { - const url = typeof window !== "undefined" ? new URL(window.location.href) : null; - const chain = url?.searchParams.get("chain") ?? null; - const res = await fetch("/api/report", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - slug, - chain, - message, - contact: wantsContact && contact ? contact : null, - page: url?.toString() ?? "", - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setErrorMsg( - typeof data?.error === "string" - ? data.error - : "Something went wrong. Try again." - ); - setStatus("error"); - return; - } - setStatus("ok"); - } catch { - setErrorMsg("Network error. Try again."); - setStatus("error"); - } - } - - const tooShort = message.trim().length < 5; - - return ( -
-
e.stopPropagation()} - > -
- - Report a problem - - -
- - {status === "ok" ? ( -
- - - -

Thanks, report received.

-

- {wantsContact && contact - ? "A maintainer will take a look and reach out at the contact you left." - : "A maintainer will take a look. Have a great day."} -

- -
- ) : ( -
-

- Spotted a wrong number, a missing provider, an outage, or - anything off about this benchmark? Tell us, it goes straight - to a maintainer. -

-
- -