diff --git a/apps/developer-hub/src/components/MinimalPlayground/feeds.ts b/apps/developer-hub/src/components/MinimalPlayground/feeds.ts
new file mode 100644
index 0000000000..55a1de610f
--- /dev/null
+++ b/apps/developer-hub/src/components/MinimalPlayground/feeds.ts
@@ -0,0 +1,51 @@
+export type LandingFeed = {
+ lazerId: number;
+ symbol: string;
+ label: string;
+ assetClass: string;
+ exponent: number;
+ displayPrecision: number;
+};
+
+export const LANDING_FEEDS: LandingFeed[] = [
+ {
+ lazerId: 1,
+ symbol: "Crypto.BTC/USD",
+ label: "BTC/USD",
+ assetClass: "Crypto",
+ exponent: -8,
+ displayPrecision: 2,
+ },
+ {
+ lazerId: 922,
+ symbol: "Equity.US.AAPL/USD",
+ label: "AAPL",
+ assetClass: "Equity",
+ exponent: -5,
+ displayPrecision: 2,
+ },
+ {
+ lazerId: 1398,
+ symbol: "Equity.US.SPY/USD",
+ label: "SPY",
+ assetClass: "Equity",
+ exponent: -5,
+ displayPrecision: 2,
+ },
+ {
+ lazerId: 327,
+ symbol: "FX.EUR/USD",
+ label: "EUR/USD",
+ assetClass: "FX",
+ exponent: -5,
+ displayPrecision: 4,
+ },
+ {
+ lazerId: 346,
+ symbol: "Metal.XAU/USD",
+ label: "XAU/USD",
+ assetClass: "Metal",
+ exponent: -3,
+ displayPrecision: 2,
+ },
+];
diff --git a/apps/developer-hub/src/components/MinimalPlayground/index.module.scss b/apps/developer-hub/src/components/MinimalPlayground/index.module.scss
new file mode 100644
index 0000000000..6a32173c70
--- /dev/null
+++ b/apps/developer-hub/src/components/MinimalPlayground/index.module.scss
@@ -0,0 +1,175 @@
+@use "@pythnetwork/component-library/theme";
+
+.section {
+ margin-top: theme.spacing(-10);
+
+ @include theme.max-width;
+}
+
+.inner {
+ display: flex;
+ flex-direction: column;
+ gap: theme.spacing(6);
+ max-width: 64rem;
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ gap: theme.spacing(2);
+ max-width: 40rem;
+}
+
+.title {
+ @include theme.text("xl", "semibold");
+
+ color: theme.color("heading");
+ margin: 0;
+}
+
+.layout {
+ display: grid;
+ gap: theme.spacing(4);
+ grid-template-columns: 1fr;
+ align-items: stretch;
+
+ @include theme.breakpoint("md") {
+ grid-template-columns: 14rem 1fr;
+ }
+}
+
+.feedList {
+ display: flex;
+ flex-direction: column;
+ gap: theme.spacing(1);
+ margin: 0;
+ padding: theme.spacing(2);
+ list-style: none;
+ background: theme.color("background", "secondary");
+ border: 1px solid theme.color("border");
+ border-radius: theme.border-radius("xl");
+}
+
+.feedItem {
+ list-style: none;
+}
+
+.feedRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: theme.spacing(2) theme.spacing(3);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: theme.border-radius("lg");
+ color: theme.color("paragraph");
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ background 120ms ease,
+ color 120ms ease;
+
+ @include theme.text("sm", "medium");
+
+ &:hover {
+ background: light-dark(
+ theme.pallette-color("white"),
+ theme.pallette-color("steel", 800)
+ );
+ color: theme.color("heading");
+ }
+
+ &:focus-visible {
+ outline: 2px solid theme.color("focus");
+ outline-offset: 2px;
+ }
+}
+
+.feedRowActive {
+ background: light-dark(
+ theme.pallette-color("violet", 50),
+ theme.pallette-color("steel", 700)
+ );
+ color: theme.color("heading");
+
+ &:hover {
+ background: light-dark(
+ theme.pallette-color("violet", 50),
+ theme.pallette-color("steel", 700)
+ );
+ }
+}
+
+.terminal {
+ display: flex;
+ flex-direction: column;
+ background: theme.pallette-color("steel", 950);
+ border: 1px solid theme.color("border");
+ border-radius: theme.border-radius("xl");
+ overflow: hidden;
+ min-height: 13rem;
+}
+
+.terminalHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: theme.spacing(2);
+ padding: theme.spacing(3) theme.spacing(5);
+ background: theme.color("background", "secondary");
+ border-bottom: 1px solid theme.color("border");
+}
+
+.terminalLabel {
+ @include theme.text("xs", "medium");
+
+ color: light-dark(
+ theme.pallette-color("stone", 500),
+ theme.pallette-color("steel", 400)
+ );
+ text-transform: uppercase;
+ letter-spacing: theme.letter-spacing("wider");
+}
+
+.terminalLabelLive {
+ color: light-dark(
+ theme.pallette-color("emerald", 600),
+ theme.pallette-color("emerald", 400)
+ );
+}
+
+.terminalActions {
+ display: flex;
+ align-items: center;
+ gap: theme.spacing(2);
+}
+
+
+.editorContainer {
+ flex: 1;
+ min-height: 10rem;
+ overflow: hidden;
+}
+
+.editorLoading {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: theme.spacing(4);
+ color: theme.pallette-color("steel", 400);
+ font-family: theme.font-family("monospace");
+
+ @include theme.text("sm", "normal");
+}
+
+.ctaRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: theme.spacing(3);
+}
diff --git a/apps/developer-hub/src/components/MinimalPlayground/index.tsx b/apps/developer-hub/src/components/MinimalPlayground/index.tsx
new file mode 100644
index 0000000000..e1e30882d4
--- /dev/null
+++ b/apps/developer-hub/src/components/MinimalPlayground/index.tsx
@@ -0,0 +1,316 @@
+"use client";
+
+import type { OnMount } from "@monaco-editor/react";
+import { Copy } from "@phosphor-icons/react/dist/ssr/Copy";
+import { Play } from "@phosphor-icons/react/dist/ssr/Play";
+import { Stop } from "@phosphor-icons/react/dist/ssr/Stop";
+import { Button } from "@pythnetwork/component-library/Button";
+import { useCopy } from "@pythnetwork/component-library/useCopy";
+import { clsx } from "clsx";
+import dynamic from "next/dynamic";
+import type { KeyboardEvent } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useStreamExecution } from "../Playground/hooks/use-stream-execution";
+import type { PlaygroundConfig } from "../Playground/types";
+import type { LandingFeed } from "./feeds";
+import { LANDING_FEEDS } from "./feeds";
+import styles from "./index.module.scss";
+
+const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
+ ssr: false,
+ loading: () =>
Loading editor…
,
+});
+
+type MonacoEditorInstance = Parameters[0];
+
+const DEFAULT_FEED: LandingFeed = LANDING_FEEDS[0]!;
+
+const buildWscatCommand = (feed: LandingFeed): string => {
+ const subscribe = JSON.stringify({
+ type: "subscribe",
+ subscriptionId: 1,
+ priceFeedIds: [feed.lazerId],
+ properties: ["price"],
+ formats: ["solana"],
+ channel: "fixed_rate@200ms",
+ parsed: true,
+ });
+ return [
+ "$ wscat -c wss://pyth-lazer.dourolabs.app/v1/stream \\",
+ ' -H "Authorization: Bearer $PYTH_PRO_KEY"',
+ "",
+ `> ${subscribe}`,
+ ].join("\n");
+};
+
+const buildConfig = (feed: LandingFeed): PlaygroundConfig => ({
+ accessToken: "",
+ priceFeedIds: [feed.lazerId],
+ properties: ["price"],
+ formats: ["solana"],
+ channel: "fixed_rate@200ms",
+ deliveryFormat: "json",
+ jsonBinaryEncoding: "hex",
+ parsed: true,
+});
+
+type StreamPayload = {
+ type?: string;
+ error?: string;
+ parsed?: {
+ priceFeeds?: { priceFeedId?: number; price?: string | number }[];
+ };
+};
+
+const formatStreamLine = (
+ event: string,
+ data: unknown,
+ feed: LandingFeed,
+): string | undefined => {
+ if (event === "connected") return "# Connected";
+ if (event === "subscribed") return `# Subscribed to ${feed.symbol}`;
+ if (event === "close") return "# Closed";
+ if (event === "error") {
+ const errData = data as { error?: string; message?: string } | undefined;
+ return `! ${errData?.error ?? errData?.message ?? "stream error"}`;
+ }
+ if (event === "message") {
+ const payload = data as StreamPayload | undefined;
+ if (payload?.type === "subscriptionError") {
+ return `! ${payload.error ?? "subscription error"}`;
+ }
+ const rawPrice = payload?.parsed?.priceFeeds?.[0]?.price;
+ if (rawPrice === undefined || rawPrice === null) return undefined;
+ const usd = Number(rawPrice) * 10 ** feed.exponent;
+ if (!Number.isFinite(usd)) return undefined;
+ const formatted = usd.toLocaleString("en-US", {
+ minimumFractionDigits: feed.displayPrecision,
+ maximumFractionDigits: feed.displayPrecision,
+ });
+ return `< ${formatted}`;
+ }
+ return undefined;
+};
+
+export const MinimalPlayground = () => {
+ const [selectedFeed, setSelectedFeed] = useState(DEFAULT_FEED);
+ const { status, messages, startStream, stopStream } = useStreamExecution();
+ const editorRef = useRef(undefined);
+
+ const command = useMemo(
+ () => buildWscatCommand(selectedFeed),
+ [selectedFeed],
+ );
+ const { isCopied, copy } = useCopy(command);
+
+ const isStreaming = status === "connecting" || status === "connected";
+
+ const handleSelect = useCallback(
+ (feed: LandingFeed) => {
+ stopStream();
+ setSelectedFeed(feed);
+ },
+ [stopStream],
+ );
+
+ const handleRun = useCallback(() => {
+ if (isStreaming) {
+ stopStream();
+ } else {
+ startStream(buildConfig(selectedFeed));
+ }
+ }, [isStreaming, selectedFeed, startStream, stopStream]);
+
+ const handleRowKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ if (isStreaming) {
+ stopStream();
+ return;
+ }
+ const lazerId = Number(event.currentTarget.dataset.lazerId);
+ const feed = LANDING_FEEDS.find((f) => f.lazerId === lazerId);
+ if (feed) {
+ setSelectedFeed(feed);
+ startStream(buildConfig(feed));
+ }
+ },
+ [isStreaming, startStream, stopStream],
+ );
+
+ const formattedLines = useMemo(
+ () =>
+ messages
+ .map((m) => ({
+ id: m.id,
+ line: formatStreamLine(m.event, m.data, selectedFeed),
+ }))
+ .filter((item): item is { id: string; line: string } =>
+ item.line !== undefined,
+ ),
+ [messages, selectedFeed],
+ );
+
+ const hasOutput = formattedLines.length > 0;
+ const showIdleHint = !isStreaming && !hasOutput;
+
+ const editorValue = useMemo(() => {
+ let text = command;
+ if (hasOutput) {
+ text += "\n\n" + formattedLines.map((f) => f.line).join("\n");
+ }
+ if (showIdleHint) {
+ text += "\n\n# Press Run or Enter to stream live updates";
+ }
+ return text;
+ }, [command, hasOutput, formattedLines, showIdleHint]);
+
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (editor && hasOutput) {
+ const model = editor.getModel();
+ if (model) {
+ editor.revealLine(model.getLineCount());
+ }
+ }
+ }, [formattedLines.length, hasOutput]);
+
+ const handleEditorMount = useCallback((editor) => {
+ editorRef.current = editor;
+ }, []);
+
+ const statusLabel = (() => {
+ if (status === "connecting") return "Connecting…";
+ if (status === "connected") return "● Live";
+ if (status === "error") return "Error";
+ if (status === "closed" && hasOutput) return "Closed";
+ return "wscat";
+ })();
+
+ return (
+
+
+
+
+
+
+ {LANDING_FEEDS.map((feed) => {
+ const isActive = feed.lazerId === selectedFeed.lazerId;
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+
+
+ {statusLabel}
+
+
+
}
+ hideText
+ onPress={copy}
+ aria-label={isCopied ? "Copied!" : "Copy command"}
+ >
+ {isCopied ? "Copied!" : "Copy"}
+
+
+ ) : (
+
+ )
+ }
+ onPress={handleRun}
+ isDisabled={status === "connecting"}
+ >
+ {isStreaming ? "Stop" : "Run"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/developer-hub/src/components/Pages/Homepage/index.module.scss b/apps/developer-hub/src/components/Pages/Homepage/index.module.scss
index 4a691f00d8..9e0e7a1680 100644
--- a/apps/developer-hub/src/components/Pages/Homepage/index.module.scss
+++ b/apps/developer-hub/src/components/Pages/Homepage/index.module.scss
@@ -19,13 +19,20 @@
}
.sectionHeroContent {
+ --max-width-padding: #{theme.spacing(4)};
+
display: flex;
flex-direction: column;
gap: theme.spacing(10);
- padding-top: theme.spacing(18);
- padding-bottom: theme.spacing(18);
-
- @include theme.max-width;
+ padding: theme.spacing(10) var(--max-width-padding);
+ width: 100%;
+ max-width: 64rem;
+ margin-left: auto;
+ margin-right: auto;
+
+ @include theme.breakpoint("sm") {
+ --max-width-padding: #{theme.spacing(6)};
+ }
@include theme.breakpoint("lg") {
flex-direction: row;
@@ -65,7 +72,18 @@
}
.sectionProducts {
- @include theme.max-width;
+ --max-width-padding: #{theme.spacing(4)};
+
+ padding-left: var(--max-width-padding);
+ padding-right: var(--max-width-padding);
+ width: 100%;
+ max-width: 64rem;
+ margin-left: auto;
+ margin-right: auto;
+
+ @include theme.breakpoint("sm") {
+ --max-width-padding: #{theme.spacing(6)};
+ }
}
.sectionHeaderTitle {
@@ -91,10 +109,66 @@
gap: theme.spacing(8);
@include theme.breakpoint("lg") {
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.sectionEntropy {
+ --max-width-padding: #{theme.spacing(4)};
+
+ padding-left: var(--max-width-padding);
+ padding-right: var(--max-width-padding);
+ width: 100%;
+ max-width: 64rem;
+ margin-left: auto;
+ margin-right: auto;
+
+ @include theme.breakpoint("sm") {
+ --max-width-padding: #{theme.spacing(6)};
+ }
+}
+
+.entropyCard {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: theme.spacing(4);
+ padding: theme.spacing(6) theme.spacing(8);
+ background: theme.color("background", "secondary");
+ border: 1px solid theme.color("border");
+ border-radius: theme.border-radius("xl");
+
+ @include theme.breakpoint("md") {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: theme.spacing(8);
}
}
+.entropyContent {
+ display: flex;
+ flex-direction: column;
+ gap: theme.spacing(1);
+}
+
+.entropyTitle {
+ @include theme.text("xl", "semibold");
+
+ color: theme.color("heading");
+ margin: 0;
+}
+
+.entropyDescription {
+ @include theme.text("base", "normal");
+
+ color: light-dark(
+ theme.pallette-color("stone", 500),
+ theme.pallette-color("steel", 400)
+ );
+ margin: 0;
+}
+
.productsCardWrapper {
position: relative;
}
diff --git a/apps/developer-hub/src/components/Pages/Homepage/index.tsx b/apps/developer-hub/src/components/Pages/Homepage/index.tsx
index 6a075e40d1..ba78db6b81 100644
--- a/apps/developer-hub/src/components/Pages/Homepage/index.tsx
+++ b/apps/developer-hub/src/components/Pages/Homepage/index.tsx
@@ -1,3 +1,6 @@
+import { Button } from "@pythnetwork/component-library/Button";
+
+import { MinimalPlayground } from "../../MinimalPlayground";
import { ProductCard } from "../../ProductCard";
import styles from "./index.module.scss";
import ResourcesForBuildersImage from "./resources-for-builders.svg";
@@ -19,13 +22,15 @@ export const Homepage = () => {
+
+
- Products
+ Global Pricing Layer
- Connect to the global market data and randomness layer.
+ Real-time price feeds for crypto, equities, FX, and metals.
- {products.map((product: ProductCardConfig) => (
+ {pricingLayerProducts.map((product: ProductCardConfig) => (
+
+
+
+
Entropy
+
+ Secure, verifiable randomness for EVM-based smart contracts.
+
+
+
+
+
+
}
isHighlight
@@ -116,7 +135,7 @@ function GradientDivider() {
return
;
}
-const products: ProductCardConfig[] = [
+const pricingLayerProducts: ProductCardConfig[] = [
{
description:
"Subscription-based price data for institutions and advanced use cases. Previously known as Lazer.",
@@ -163,29 +182,6 @@ const products: ProductCardConfig[] = [
],
title: "Pyth Core",
},
- {
- description:
- "Secure, Verifiable Random Number Generator for EVM-based smart contracts.",
- features: [
- { label: "On-chain randomness" },
- { label: "Verifiable results" },
- { label: "Pay in native token" },
- { label: "Supports 20+ EVM chains" },
- ],
- href: "/entropy",
- quickLinks: [
- {
- href: "/entropy/chainlist",
- label: "Chainlist",
- },
- { href: "/entropy/protocol-design", label: "Protocol Design" },
- {
- href: "https://entropy-explorer.pyth.network/",
- label: "Entropy Explorer",
- },
- ],
- title: "Entropy",
- },
];
type ProductCardConfig = {
diff --git a/apps/developer-hub/src/components/Pages/Homepage/section.module.scss b/apps/developer-hub/src/components/Pages/Homepage/section.module.scss
index 727fabff5f..3943e0b788 100644
--- a/apps/developer-hub/src/components/Pages/Homepage/section.module.scss
+++ b/apps/developer-hub/src/components/Pages/Homepage/section.module.scss
@@ -8,7 +8,7 @@
.sectionContent {
margin: 0 auto;
width: 100%;
- max-width: theme.$max-width;
+ max-width: 64rem;
}
.sectionHeader {