diff --git a/apps/docs/README.md b/apps/docs/README.md
index 94bd3d11..5f0f6f9f 100644
--- a/apps/docs/README.md
+++ b/apps/docs/README.md
@@ -83,14 +83,14 @@ from the same Markdown frontmatter used by the site; new pages receive cards aut
metadata share the same image, title, description, and accessible image text. Canonical URLs and social image URLs
always point to the published GitHub Pages site, including when previewing locally.
-- `lib/og-image.tsx` defines the GitHub-style card layout: a `section/Title` path headline, description, app icon,
- a facts row with the wordmark, and the brand palette as a bottom bar.
+- `lib/og-image.tsx` defines the GitHub-style card layout shared with the chart and sfx sites: an owner/repo
+ headline, description, app icon, a stats row counted from the SDK's clients, and the brand palette as a bottom bar.
- `lib/brand.ts` vendors the official brand geometry from the Bloxwap monorepo's `packages/tokens/src/brand.ts`.
Cards draw the wordmark from these SVG paths. Re-copy the geometry from the monorepo when the marks change.
- `lib/social.ts` defines the published URL and metadata. Update it if the repository or public hostname changes.
-- `app/og/[...slug]/route.tsx` generates `/og/index.png`, `/og/docs.png`, and `/og/docs/.png` during the static build.
+- `app/og/[...slug]/route.tsx` generates `/og/home.png`, `/og/docs.png`, and `/og/docs/.png` during the static build.
-Cards use Nunito Black for page titles and Nunito Bold for the path prefix, description, and facts. The static Nunito
+Cards use Nunito Black for titles and Nunito Bold for the owner, description, and stats. The static Nunito
faces come from the Bloxwap monorepo's `packages/og/assets/`; their license is included as
`public/fonts/Nunito-OG-OFL.txt`.
The renderer uses bundled fonts and brand geometry locally; it needs no font CDN, API credentials, or image server after
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
index 79817ed8..cb76588f 100644
--- a/apps/docs/app/global.css
+++ b/apps/docs/app/global.css
@@ -357,6 +357,201 @@ textarea:focus-visible {
color: var(--color-text-muted);
}
+/* Live market data playground: its own section below the hero, like the chart and sfx sites. */
+.playground-section {
+ margin-top: --spacing(24);
+ /* The hero's "Try the playground" jump lands the intro below the sticky header. */
+ scroll-margin-top: --spacing(20);
+}
+@media (prefers-reduced-motion: no-preference) {
+ html:has(.landing) {
+ scroll-behavior: smooth;
+ }
+}
+.section-intro {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: --spacing(4);
+ margin-bottom: --spacing(8);
+ text-align: center;
+}
+.section-intro h2 {
+ font-family: var(--font-docs-display), var(--font-sans);
+ font-size: var(--text-3xl);
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.04em;
+}
+.section-intro > p:not(.eyebrow) {
+ max-width: 440px;
+ font-size: var(--text-sm);
+ line-height: 1.7;
+ color: var(--color-text-muted);
+}
+.playground {
+ min-width: 0;
+ transform: none;
+}
+/* Wide: controls and code on the left, the live output filling the right column. */
+.playground-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+.playground-editor {
+ min-width: 0;
+}
+.playground-controls {
+ display: grid;
+ gap: --spacing(4);
+ padding: --spacing(6) --spacing(6) 0;
+}
+.playground-controls fieldset {
+ min-width: 0;
+}
+.playground-controls legend {
+ display: flex;
+ gap: --spacing(3);
+ margin-bottom: --spacing(2);
+ font-size: var(--text-2xs);
+ color: var(--color-text-muted);
+}
+.playground-controls legend > span {
+ color: var(--color-text-quiet);
+}
+.playground-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: --spacing(1);
+}
+.playground-options button {
+ min-height: 32px;
+ padding: --spacing(1) --spacing(3);
+ border-radius: calc(var(--radius) - 2px);
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ background: var(--color-surface);
+ transition:
+ color var(--duration-interactive) var(--ease-out),
+ background var(--duration-interactive) var(--ease-out);
+}
+.playground-options button:hover {
+ color: var(--color-text);
+ background: var(--color-fill-subtle);
+}
+.playground-options button[aria-pressed="true"] {
+ color: var(--color-accent-on);
+ background: var(--color-accent);
+}
+.playground-code {
+ position: relative;
+}
+.playground-code-viewport {
+ overflow: hidden;
+}
+.playground-code-line {
+ display: block;
+ min-height: 1.85em;
+}
+.playground .playground-code-measure {
+ position: absolute;
+ inset: 0 0 auto;
+ visibility: hidden;
+ pointer-events: none;
+}
+/* Size containment: the code column sets the row height and the output scrolls within it. */
+.playground-output {
+ contain: size;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ margin: --spacing(4) --spacing(4) --spacing(4) 0;
+ padding: --spacing(4);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface);
+}
+.playground-output-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: --spacing(2);
+ min-height: 20px;
+ font-family: var(--font-mono);
+ font-size: var(--text-2xs);
+ color: var(--color-text-muted);
+}
+.playground-status {
+ display: inline-flex;
+ align-items: center;
+ gap: --spacing(2);
+}
+.playground-status i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-text-quiet);
+}
+.playground-status[data-status="live"] {
+ color: var(--color-accent);
+}
+.playground-status[data-status="live"] i {
+ background: var(--color-accent);
+ animation: playground-pulse 2s ease-in-out infinite;
+}
+.playground-status[data-status="connecting"] i,
+.playground-status[data-status="waiting"] i,
+.playground-status[data-status="reconnecting"] i {
+ animation: playground-pulse 1s ease-in-out infinite;
+}
+.playground-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: --spacing(2);
+ min-height: 128px;
+ text-align: center;
+}
+.playground .playground-empty > svg {
+ color: var(--color-text-quiet);
+ margin-bottom: --spacing(1);
+}
+.playground-empty p {
+ font-size: var(--text-sm);
+}
+.playground-empty > span {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+.playground .playground-json {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: --spacing(3) 0 0;
+ color: var(--color-code-string);
+}
+.playground-retry {
+ display: inline-flex;
+ align-items: center;
+ gap: --spacing(1);
+ color: var(--color-text);
+}
+@keyframes playground-pulse {
+ 50% {
+ opacity: 0.4;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .playground-status i {
+ animation: none;
+ }
+ .playground-options button {
+ transition: none;
+ }
+}
+
/* Feature links: fill-only cards, matching the code window. */
.feature-grid {
display: grid;
@@ -499,13 +694,13 @@ textarea:focus-visible {
.hero-copy > .eyebrow,
.hero-copy > h1,
.hero-copy > :not(.eyebrow, h1),
-.code-window {
+.hero .code-window {
animation: landing-rise 600ms var(--ease-out) both;
}
.hero-copy > :not(.eyebrow, h1) {
animation-delay: 200ms;
}
-.code-window {
+.hero .code-window {
animation-delay: 400ms;
}
@@ -744,10 +939,25 @@ textarea:focus-visible {
.hero-description {
max-width: 500px;
}
- .code-window {
+ .hero .code-window {
max-width: 580px;
transform: none;
}
+ .playground-section {
+ margin-top: --spacing(16);
+ }
+ .playground-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .playground-output {
+ contain: none;
+ margin: 0 --spacing(3) --spacing(3);
+ }
+ .playground .playground-json {
+ flex: none;
+ min-height: 128px;
+ max-height: 260px;
+ }
.feature-grid {
margin-top: --spacing(12);
gap: --spacing(4);
@@ -792,7 +1002,7 @@ textarea:focus-visible {
}
@media (prefers-reduced-motion: reduce) {
.hero-copy > *,
- .code-window,
+ .hero .code-window,
.search-skeleton > span {
animation: none;
}
diff --git a/apps/docs/app/og/[...slug]/route.tsx b/apps/docs/app/og/[...slug]/route.tsx
index 7cbe931e..e483ea86 100644
--- a/apps/docs/app/og/[...slug]/route.tsx
+++ b/apps/docs/app/og/[...slug]/route.tsx
@@ -14,19 +14,24 @@ export function generateStaticParams() {
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const path = `/og/${(await params).slug.join("/")}`;
if (path === socialImagePath("/")) {
- return createSocialImage({ prefix: "bloxwap", title: "hyperliquid", description: homeDescription });
+ return createSocialImage({
+ title: "bloxwap/hyperliquid",
+ description: homeDescription,
+ category: "Docs",
+ home: true,
+ });
}
const page = source.getPages().find((page) => socialImagePath(page.url) === path);
if (!page) return new Response("Not found", { status: 404 });
return createSocialImage({
- prefix: page.url.startsWith("/docs/guides")
- ? "guides"
- : page.url.startsWith("/docs/reference")
- ? "reference"
- : "docs",
title: page.data.title,
description: page.data.description ?? homeDescription,
+ category: page.url.startsWith("/docs/guides")
+ ? "Guides"
+ : page.url.startsWith("/docs/reference")
+ ? "Reference"
+ : "Docs",
});
}
diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx
index 055f2725..f3547744 100644
--- a/apps/docs/app/page.tsx
+++ b/apps/docs/app/page.tsx
@@ -1,8 +1,9 @@
import { HomeLayout } from "fumadocs-ui/layouts/home";
-import { ArrowRight } from "lucide-react";
+import { ArrowDown, ArrowRight } from "lucide-react";
import Link from "next/link";
import { CopyButton } from "@/components/copy-button";
import { InstallCommand } from "@/components/install-command";
+import { MarketPlayground } from "@/components/market-playground";
import { SiteFooter } from "@/components/site-footer";
import { baseOptions } from "@/lib/layout.shared";
import { siteUrl } from "@/lib/social";
@@ -109,12 +110,12 @@ export default function Home() {
JavaScript.
Bun · Node.js · Browsers · React Native
@@ -161,6 +162,16 @@ export default function Home() {
+
+
+
+ LIVE · HYPERLIQUID MAINNET
+
+
Market data playground
+
Pick an asset and a feed to stream real market data over a WebSocket. The code updates as you go.
+
+
+
{features.map((feature) => (
diff --git a/apps/docs/components/market-playground.tsx b/apps/docs/components/market-playground.tsx
new file mode 100644
index 00000000..38d26a8e
--- /dev/null
+++ b/apps/docs/components/market-playground.tsx
@@ -0,0 +1,375 @@
+"use client";
+
+import { SubscriptionClient } from "@bloxwap/hyperliquid/api/subscription/client";
+import { WebSocketTransport } from "@bloxwap/hyperliquid/transport/websocket";
+import { animate } from "animejs/animation";
+import { scrambleText } from "animejs/text";
+import { Radio, RotateCcw } from "lucide-react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { CopyButton } from "@/components/copy-button";
+
+const assets = [
+ { coin: "BTC", label: "BTC" },
+ { coin: "HYPE", label: "HYPE" },
+ { coin: "SOL", label: "Solana" },
+ { coin: "ETH", label: "Ethereum" },
+] as const;
+
+const feeds = [
+ { id: "allMids", label: "Mid price" },
+ { id: "l2Book", label: "Order book" },
+ { id: "trades", label: "Trades" },
+ { id: "candle", label: "Candles" },
+ { id: "activeAssetCtx", label: "Market context" },
+] as const;
+
+type Coin = (typeof assets)[number]["coin"];
+type Feed = (typeof feeds)[number]["id"];
+type Status = "off" | "connecting" | "waiting" | "live" | "reconnecting" | "error";
+type Stream = { status: Status; payload: string | null; count: number; receivedAt: number | null };
+const idle: Stream = { status: "off", payload: null, count: 0, receivedAt: null };
+
+function snippet(coin: Coin, feed: Feed | null): string {
+ if (!feed) {
+ return `// Explore live Hyperliquid market data.
+//
+// 1. Choose an asset: ${coin}
+// 2. Select a data feed below
+//
+// Your subscription code will appear here.
+// No connection opens until you select a feed.`;
+ }
+ const call =
+ feed === "allMids"
+ ? `client.allMids({}, (data) => {
+ console.log({ coin: "${coin}", midPx: data.mids["${coin}"] });
+})`
+ : `client.${feed}(
+ { coin: "${coin}"${feed === "candle" ? ', interval: "1m"' : ""} },
+ (data) => console.log(data),
+)`;
+ return `import { SubscriptionClient }
+ from "@bloxwap/hyperliquid/api/subscription/client";
+import { WebSocketTransport }
+ from "@bloxwap/hyperliquid/transport/websocket";
+
+const transport = new WebSocketTransport();
+const client = new SubscriptionClient({ transport });
+
+// Stream ${feeds.find((item) => item.id === feed)?.label.toLowerCase()} for ${coin}
+const sub = await ${call};
+
+// When you're done streaming:
+// await sub.unsubscribe();
+// transport.close();`;
+}
+
+/** Build syntax highlighting with text nodes, including while the code is being scrambled. */
+function highlight(element: HTMLElement, text: string): void {
+ const nodes: Node[] = [];
+ let offset = 0;
+ for (const match of text.matchAll(
+ /\/\/[^\n]*|"(?:[^"\\]|\\.)*"|\b(?:import|from|const|new|await|SubscriptionClient|WebSocketTransport)\b/g,
+ )) {
+ nodes.push(document.createTextNode(text.slice(offset, match.index)));
+ const token = document.createElement("span");
+ token.className = match[0].startsWith("//")
+ ? "syntax-muted"
+ : match[0].startsWith('"')
+ ? "syntax-green"
+ : /Client|Transport/.test(match[0])
+ ? "syntax-blue"
+ : "syntax-purple";
+ token.textContent = match[0];
+ nodes.push(token);
+ offset = match.index + match[0].length;
+ }
+ nodes.push(document.createTextNode(text.slice(offset)));
+ element.replaceChildren(...nodes);
+}
+
+function AnimatedCode({ text }: { text: string }) {
+ const initial = useRef(text);
+ const codeRef = useRef
(null);
+ const measureRef = useRef(null);
+ const viewportRef = useRef(null);
+ const previous = useRef(text);
+
+ useLayoutEffect(() => {
+ const code = codeRef.current;
+ const viewport = viewportRef.current;
+ const measure = measureRef.current;
+ if (!code || !viewport || !measure) return;
+ const oldHeight = viewport.getBoundingClientRect().height;
+ const newHeight = measure.getBoundingClientRect().height;
+ if (previous.current === text || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ highlight(code, text);
+ previous.current = text;
+ viewport.style.height = "auto";
+ return;
+ }
+
+ // Pin the current height before swapping in the new line count, so the first frame (and a
+ // Strict Mode re-run of this effect) still starts from the old height.
+ viewport.style.height = `${oldHeight}px`;
+
+ // Animate each line independently so scrambling never removes the code's line breaks.
+ const oldLines = previous.current.split("\n");
+ const newLines = text.split("\n");
+ const lines = newLines.map((line, index) => {
+ const span = document.createElement("span");
+ span.className = "playground-code-line";
+ span.textContent = oldLines[index] || line;
+ return span;
+ });
+ code.replaceChildren(...lines);
+ const reveal = animate(lines, {
+ innerHTML: scrambleText({ text: (_target, index) => newLines[index], chars: "a-z0-9_", duration: 420 }),
+ onComplete: () => {
+ highlight(code, text);
+ // Committed only once the reveal lands: a cancelled run (Strict Mode, or a quick second
+ // click) must animate again rather than snap.
+ previous.current = text;
+ },
+ });
+ const resize = animate(viewport, {
+ height: [`${oldHeight}px`, `${newHeight}px`],
+ duration: 420,
+ ease: "out(3)",
+ onComplete: () => {
+ viewport.style.height = "auto";
+ },
+ });
+ return () => {
+ reveal.cancel();
+ resize.cancel();
+ };
+ }, [text]);
+
+ return (
+
+
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the code horizontally. */}
+
+ {initial.current}
+
+
+
+ {text}
+
+
+ );
+}
+
+/** One reusable socket, with abortable subscriptions and coalesced UI updates. */
+function useMarketStream(coin: Coin, feed: Feed | null, retry: number): Stream {
+ const transportRef = useRef(null);
+ const [stream, setStream] = useState(idle);
+
+ useEffect(
+ () => () => {
+ transportRef.current?.close();
+ transportRef.current = null;
+ },
+ [],
+ );
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Retry intentionally restarts the subscription after a connection failure.
+ useEffect(() => {
+ if (!feed) {
+ transportRef.current?.close();
+ transportRef.current = null;
+ setStream(idle);
+ return;
+ }
+ const controller = new AbortController();
+ const transport = transportRef.current ?? new WebSocketTransport();
+ transportRef.current = transport;
+ const client = new SubscriptionClient({ transport });
+ let timer: ReturnType | undefined;
+ let count = 0;
+ let latest: unknown;
+ let receivedAt = 0;
+ setStream({ ...idle, status: "connecting" });
+
+ // Bound rendering to four updates per second, while counting every event received.
+ const publish = () => {
+ timer = undefined;
+ if (!controller.signal.aborted) {
+ setStream({ status: "live", payload: JSON.stringify(latest, null, 2), count, receivedAt });
+ }
+ };
+ const receive = (data: unknown) => {
+ if (controller.signal.aborted) return;
+ latest = data;
+ count++;
+ receivedAt = Date.now();
+ timer ??= setTimeout(publish, 250);
+ };
+ const fail = () => {
+ if (controller.signal.aborted) return;
+ clearTimeout(timer);
+ controller.abort();
+ transport.close();
+ if (transportRef.current === transport) transportRef.current = null;
+ setStream({ ...idle, status: "error" });
+ };
+ transport.events.addEventListener(
+ "connectionstatechange",
+ (event) => {
+ if (event.detail === "connected") {
+ setStream((current) => ({ ...current, status: "waiting" }));
+ } else if (event.detail === "reconnecting" || event.detail === "connecting") {
+ clearTimeout(timer);
+ timer = undefined;
+ setStream((current) => ({ ...current, status: "reconnecting" }));
+ } else if (event.detail === "disconnected") {
+ fail();
+ }
+ },
+ { signal: controller.signal },
+ );
+
+ const options = { signal: controller.signal, onError: fail };
+ const subscription =
+ feed === "allMids"
+ ? client.allMids(
+ {},
+ (data) => {
+ if (data.mids[coin] !== undefined) receive({ coin, midPx: data.mids[coin] });
+ },
+ options,
+ )
+ : feed === "l2Book"
+ ? client.l2Book({ coin }, receive, options)
+ : feed === "trades"
+ ? client.trades({ coin }, receive, options)
+ : feed === "candle"
+ ? client.candle({ coin, interval: "1m" }, receive, options)
+ : client.activeAssetCtx({ coin }, receive, options);
+
+ subscription
+ .then(() => {
+ if (!controller.signal.aborted) {
+ setStream((current) => (current.status === "live" ? current : { ...current, status: "waiting" }));
+ }
+ })
+ .catch(fail);
+ return () => {
+ controller.abort();
+ clearTimeout(timer);
+ };
+ }, [coin, feed, retry]);
+
+ return stream;
+}
+
+const statusLabels: Record = {
+ off: "Stream off",
+ connecting: "Connecting",
+ waiting: "Waiting for data",
+ live: "Live",
+ reconnecting: "Reconnecting",
+ error: "Connection unavailable",
+};
+
+export function MarketPlayground() {
+ const [coin, setCoin] = useState("BTC");
+ const [feed, setFeed] = useState(null);
+ const [retry, setRetry] = useState(0);
+ const stream = useMarketStream(coin, feed, retry);
+ const code = snippet(coin, feed);
+
+ return (
+
+
+
+
+
+
+
+
playground.ts
+
+ TS
+
+
+
+
+
+
+
+ Asset
+
+ {assets.map((asset) => (
+ setCoin(asset.coin)}
+ >
+ {asset.label}
+
+ ))}
+
+
+
+
+ Data feed Select to start streaming
+
+
+ setFeed(null)}>
+ Off
+
+ {feeds.map((item) => (
+ setFeed(item.id)}>
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {statusLabels[stream.status]}
+
+ {stream.receivedAt !== null && (
+
+ {stream.count.toLocaleString()} events · {new Date(stream.receivedAt).toLocaleTimeString()}
+
+ )}
+ {stream.status === "off" && Mainnet }
+ {stream.status === "error" && (
+ setRetry((value) => value + 1)}>
+ Retry
+
+ )}
+
+ {stream.payload ? (
+ /* biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the live JSON. */
+
+ {stream.payload}
+
+ ) : (
+
+
+
+ {stream.status === "off"
+ ? "Pick a feed. Watch the market move."
+ : stream.status === "error"
+ ? "Couldn't connect. Retry or choose another feed."
+ : "Listening for the first update…"}
+
+
+ {stream.status === "off" ? "Real data. Your code. No API key needed." : `${coin} · Hyperliquid mainnet`}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/docs/lib/og-image.tsx b/apps/docs/lib/og-image.tsx
index 6e07ae65..ed9857c0 100644
--- a/apps/docs/lib/og-image.tsx
+++ b/apps/docs/lib/og-image.tsx
@@ -1,153 +1,108 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
+import { ExchangeClient } from "@bloxwap/hyperliquid/api/exchange/client";
+import { InfoClient } from "@bloxwap/hyperliquid/api/info/client";
+import { SubscriptionClient } from "@bloxwap/hyperliquid/api/subscription/client";
import { ImageResponse } from "next/og";
-import { BRAND_GREEN, BRAND_ICON, BRAND_WORDMARK, BRAND_X } from "@/lib/brand";
+import { BRAND_GREEN, BRAND_ICON } from "@/lib/brand";
import { socialImageSize } from "@/lib/social";
-// The Bloxwap social card, laid out like a GitHub repository card: a "prefix/title" path headline
-// and description on the left, the app icon as the avatar top right, a row of facts with the
-// wordmark bottom right, and the brand palette as the language bar along the bottom edge.
-// Colors are sRGB hex for the Satori/resvg renderer.
+// Modeled on GitHub's repository cards in dark mode, matching the chart and sfx docs cards: the site's black
+// canvas, an owner/repo title, a muted description, the logo tile top right, a stats row, and the Bloxwap
+// brand palette as the color bar along the bottom. Colors are sRGB hex for the Satori/resvg renderer.
const BRAND = {
black: "#0A0A0A",
- text: "#F5F7FA",
- muted: "rgba(245,247,250,0.62)",
+ ink: "#F5F7FA",
+ muted: "#A1A1A1",
};
-/** The brand palette in the monorepo's order, drawn as equal segments of the bottom bar. */
-const PALETTE = [BRAND_GREEN, "#35B5FF", "#B300FF", "#FF479C", "#FFFB38"] as const;
+/** The brand palette weighted like a GitHub language bar, green leading. */
+const BAR: [color: string, share: number][] = [
+ [BRAND_GREEN, 62],
+ ["#35B5FF", 14],
+ ["#B300FF", 10],
+ ["#FF479C", 8],
+ ["#FFFB38", 6],
+];
const assets = Promise.all([
readFile(join(process.cwd(), "public/fonts/Nunito-Bold.ttf")),
readFile(join(process.cwd(), "public/fonts/Nunito-Black.ttf")),
+ readFile(join(process.cwd(), "../../packages/hyperliquid/package.json"), "utf8"),
]);
-/** Headline size and leading step down as the title grows; the path prefix counts toward it. */
-const HEADLINE_TIERS = [
- { max: 24, size: 76, line: 1.08 },
- { max: 44, size: 64, line: 1.1 },
- { max: Number.POSITIVE_INFINITY, size: 54, line: 1.12 },
-] as const;
-
-/** Lucide icon paths (24px grid, 2px round strokes) for the facts row. */
+/** Lucide 24px stroke icons, inlined so the renderer needs no icon font or component. */
const ICONS = {
- code: ["m18 16 4-4-4-4", "m6 8-4 4 4 4", "m14.5 4-5 16"],
- scale: [
- "M12 3v18",
- "m19 8 3 8a5 5 0 0 1-6 0zV7",
- "M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1",
- "m5 8 3 8a5 5 0 0 1-6 0zV7",
- "M7 21h10",
+ info: ["m21 21-4.34-4.34", "M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0"],
+ exchange: ["M8 3 4 7l4 4", "M4 7h16", "m16 21 4-4-4-4", "M20 17H4"],
+ subscription: [
+ "M16.247 7.761a6 6 0 0 1 0 8.478",
+ "M19.075 4.933a10 10 0 0 1 0 14.134",
+ "M4.925 19.067a10 10 0 0 1 0-14.134",
+ "M7.753 16.239a6 6 0 0 1 0-8.478",
+ "M10 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
+ ],
+ version: [
+ "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z",
+ "M7.5 7.5h.01",
],
+ docs: ["M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"],
} as const;
-/** Clamp by code point so a surrogate pair is never split. */
-function clamp(text: string, max: number): string {
- const points = [...text.trim()];
- return points.length > max ? `${points.slice(0, max - 1).join("")}…` : points.join("");
-}
-
-// Wordmark ink bounds in viewBox units: the "B" stem starts at 14.2, the "p" bowl ends at 803.2.
-const WORDMARK_INK = { left: 14.2, right: 803.2 };
-
-/**
- * The drawn wordmark laid out on its letters (cap top to baseline, "B" stem to "p"), so flex
- * alignment measures against the visible type. Ascender, overshoots and descender draw outside.
- */
-function Wordmark({ capHeight }: { capHeight: number }) {
- const scale = capHeight / BRAND_WORDMARK.capHeight;
- return (
-
-
-
-
-
-
- {BRAND_X.paths.map((d) => (
-
- ))}
-
-
-
-
-
- );
-}
-
-/** The app icon (bloxwap-icon-green.svg) in the avatar slot. */
-function AppIcon({ size }: { size: number }) {
+function Icon({ name }: { name: keyof typeof ICONS }) {
return (
-
-
- {BRAND_ICON.paths.map((d) => (
-
- ))}
-
+ {ICONS[name].map((d) => (
+
+ ))}
);
}
-function Fact({ icon, label }: { icon: keyof typeof ICONS; label: string }) {
- return (
-
-
-
- {ICONS[icon].map((d) => (
-
- ))}
-
-
-
{label}
-
- );
+/** Public methods on a client: one per Info query, Exchange action, or subscription. */
+function methodCount(client: { prototype: object }): number {
+ return Object.getOwnPropertyNames(client.prototype).filter((name) => name !== "constructor").length;
+}
+
+/** Clamp by code point so a surrogate pair is never split. */
+function clamp(text: string, max: number): string {
+ const points = [...text.trim()];
+ return points.length > max ? `${points.slice(0, max - 1).join("")}…` : points.join("");
}
type Card = {
- /** Muted path segment before the slash, e.g. "bloxwap" or "guides". */
- prefix: string;
title: string;
description: string;
+ /** Page section after "bloxwap/hyperliquid ·" on docs cards, e.g. "Guides". */
+ category: string;
+ /** The landing card: the owner/repo title and the stats row. */
+ home?: boolean;
};
/** Rendered at build time; GitHub Pages serves the resulting PNG without a server. */
-export async function createSocialImage({ prefix, title, description }: Card) {
- const [bold, black] = await assets;
- const headline = clamp(title, 72);
- const tier =
- HEADLINE_TIERS.find((t) => prefix.length + 1 + headline.length <= t.max) ??
- HEADLINE_TIERS[HEADLINE_TIERS.length - 1];
- // Satori wraps flex items, not text runs, so each word is its own item after the prefix.
- const words = headline.split(/\s+/);
+export async function createSocialImage({ title, description, category, home = false }: Card) {
+ const [bold, black, manifest] = await assets;
+ const { version } = JSON.parse(manifest) as { version: string };
+ const stats: [icon: keyof typeof ICONS, value: string, label: string][] = [
+ ["info", String(methodCount(InfoClient)), "Info queries"],
+ ["exchange", String(methodCount(ExchangeClient)), "Exchange actions"],
+ ["subscription", String(methodCount(SubscriptionClient)), "Subscriptions"],
+ ["version", `v${version}`, "Latest"],
+ ];
+ const headline = clamp(title, 60);
+ const fontSize = home ? 72 : headline.length <= 20 ? 84 : headline.length <= 34 ? 72 : 60;
return new ImageResponse(
-
-
-
-
- {prefix}/
- {words.map((word, i) => (
-
- {word}
-
- ))}
-
-
- {clamp(description, 150)}
-
+
+
+
+ {home ? (
+ <>
+ bloxwap/
+ hyperliquid
+ >
+ ) : (
+ {headline}
+ )}
-
-
-
-
-
-
-
+
+ {clamp(description, 150)}
-
+
+
+
+ {BRAND_ICON.paths.map((d) => (
+
+ ))}
+
+
+
+
+ {home ? (
+ stats.map(([icon, value, label]) => (
+
+
+
+ {value}
+
+
{label}
+
+ ))
+ ) : (
+
+
+ bloxwap/hyperliquid
+ ·
+ {category}
+
+ )}
-
- {PALETTE.map((color) => (
-
+
+ {BAR.map(([color, share]) => (
+
))}
,
diff --git a/apps/docs/lib/social.ts b/apps/docs/lib/social.ts
index 63901bf9..8d9c89ae 100644
--- a/apps/docs/lib/social.ts
+++ b/apps/docs/lib/social.ts
@@ -5,8 +5,9 @@ export const socialImageSize = { width: 1200, height: 630 };
export const homeDescription =
"A fast, typed Hyperliquid SDK for TypeScript and JavaScript. Connect, trade, and subscribe with Bloxwap.";
+/** Explicit PNG paths work on GitHub Pages without an image service; the landing card is home.png. */
export function socialImagePath(pageUrl: string) {
- return `/og/${pageUrl.replace(/^\/+|\/+$/g, "") || "index"}.png`;
+ return `/og/${pageUrl.replace(/^\/+|\/+$/g, "") || "home"}.png`;
}
export function socialMetadata(pageUrl: string, title: string, description: string): Metadata {
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 2f3239d4..ab22fc3f 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -10,6 +10,8 @@
"check:content": "bun run scripts/check-content.ts && markdownlint-cli2 --config ../../.markdownlint-cli2.jsonc ../../README.md ../../packages/hyperliquid/README.md README.md \"content/**/*.md\""
},
"dependencies": {
+ "@bloxwap/hyperliquid": "workspace:*",
+ "animejs": "4.4.0",
"fumadocs-core": "16.15.14",
"fumadocs-mdx": "15.4.5",
"fumadocs-ui": "16.15.14",
diff --git a/apps/docs/scripts/verify-export.ts b/apps/docs/scripts/verify-export.ts
index bced9cb6..b24881dc 100644
--- a/apps/docs/scripts/verify-export.ts
+++ b/apps/docs/scripts/verify-export.ts
@@ -184,7 +184,7 @@ for (const [file, route] of contentPages) {
// Social metadata always describes the published site, including in local builds
// whose assets and navigation are served without the GitHub Pages base path.
const canonicalUrl = `${publishedOrigin}${publishedBasePath}${route}`;
- const card = `og/${route.replace(/^\/+|\/+$/g, "") || "index"}.png`;
+ const card = `og/${route.replace(/^\/+|\/+$/g, "") || "home"}.png`;
const imageUrl = `${publishedOrigin}${publishedBasePath}/${card}`;
expectedCards.add(card);
const canonical = requiredValue(page.canonicals, `${file}: canonical URL`);
diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json
index dec34e70..5ec5aa09 100644
--- a/apps/docs/tsconfig.json
+++ b/apps/docs/tsconfig.json
@@ -6,6 +6,7 @@
"skipLibCheck": true,
"strict": true,
"noEmit": true,
+ "allowImportingTsExtensions": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
diff --git a/bun.lock b/bun.lock
index 8b81932b..eb99e68a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,8 @@
"apps/docs": {
"name": "@bloxwap/hyperliquid-docs",
"dependencies": {
+ "@bloxwap/hyperliquid": "workspace:*",
+ "animejs": "4.4.0",
"fumadocs-core": "16.15.14",
"fumadocs-mdx": "15.4.5",
"fumadocs-ui": "16.15.14",
@@ -460,6 +462,8 @@
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+ "animejs": ["animejs@4.4.0", "", {}, "sha512-GcMGjPjWP6VLjOi8Z0RCLfKS4LiPc2TkXNtdCadsDFsVkRoKt/hyXyV8MSKOUy1KULLzXt0yigdRYfEcxlo9Dw=="],
+
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],