ソースを参照

Merge pull request #2167 from cprussin/finish-publishers-index-page

feat(insights): mostly finish publishers index page
Connor Prussin 11 ヶ月 前
コミット
1306817cbe
73 ファイル変更1954 行追加1133 行削除
  1. 4 0
      apps/insights/package.json
  2. 8 5
      apps/insights/src/app/global-error.tsx
  3. 0 1
      apps/insights/src/app/publishers/layout.ts
  4. 0 1
      apps/insights/src/app/publishers/loading.ts
  5. 2 2
      apps/insights/src/app/yesterdays-prices/route.ts
  6. 21 0
      apps/insights/src/components/AsyncValue/index.tsx
  7. 26 0
      apps/insights/src/components/FormattedTokens/index.tsx
  8. 0 7
      apps/insights/src/components/H1/index.module.scss
  9. 0 10
      apps/insights/src/components/H1/index.tsx
  10. 1 1
      apps/insights/src/components/LivePrices/index.tsx
  11. 0 5
      apps/insights/src/components/Loading/index.module.scss
  12. 0 12
      apps/insights/src/components/Loading/index.tsx
  13. 8 0
      apps/insights/src/components/Overview/index.module.scss
  14. 1 2
      apps/insights/src/components/Overview/index.tsx
  15. 0 7
      apps/insights/src/components/PriceFeeds/asset-classes-card.module.scss
  16. 27 46
      apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx
  17. 15 0
      apps/insights/src/components/PriceFeeds/coming-soon-list.module.scss
  18. 10 48
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  19. 0 23
      apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.module.scss
  20. 0 11
      apps/insights/src/components/PriceFeeds/featured-coming-soon.module.scss
  21. 0 68
      apps/insights/src/components/PriceFeeds/featured-coming-soon.tsx
  22. 30 40
      apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss
  23. 11 79
      apps/insights/src/components/PriceFeeds/featured-recently-added.tsx
  24. 26 5
      apps/insights/src/components/PriceFeeds/index.module.scss
  25. 108 114
      apps/insights/src/components/PriceFeeds/index.tsx
  26. 0 17
      apps/insights/src/components/PriceFeeds/num-active-feeds.tsx
  27. 0 9
      apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss
  28. 235 265
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  29. 16 9
      apps/insights/src/components/PriceFeeds/query-params.ts
  30. 168 0
      apps/insights/src/components/PriceFeeds/use-filtered-price-feeds.tsx
  31. 0 30
      apps/insights/src/components/Publishers/columns.ts
  32. 0 8
      apps/insights/src/components/Publishers/epoch-select.module.scss
  33. 0 38
      apps/insights/src/components/Publishers/epoch-select.tsx
  34. 142 22
      apps/insights/src/components/Publishers/index.module.scss
  35. 206 44
      apps/insights/src/components/Publishers/index.tsx
  36. 0 13
      apps/insights/src/components/Publishers/layout.module.scss
  37. 0 19
      apps/insights/src/components/Publishers/layout.tsx
  38. 0 10
      apps/insights/src/components/Publishers/loading.tsx
  39. 66 0
      apps/insights/src/components/Publishers/publishers-card.module.scss
  40. 323 0
      apps/insights/src/components/Publishers/publishers-card.tsx
  41. 0 103
      apps/insights/src/components/Publishers/results.tsx
  42. 95 0
      apps/insights/src/components/Publishers/semicircle-meter.tsx
  43. 1 0
      apps/insights/src/components/Root/footer.module.scss
  44. 1 0
      apps/insights/src/components/Root/header.module.scss
  45. 1 1
      apps/insights/src/components/Root/header.tsx
  46. 2 1
      apps/insights/src/components/Root/index.tsx
  47. 3 3
      apps/insights/src/components/Root/logo.svg
  48. 64 0
      apps/insights/src/components/Root/tab-panel.tsx
  49. 23 0
      apps/insights/src/components/TokenIcon/index.module.scss
  50. 17 0
      apps/insights/src/components/TokenIcon/index.tsx
  51. 2 0
      apps/insights/src/icons.tsx
  52. 3 1
      apps/insights/src/services/clickhouse.ts
  53. 5 0
      apps/insights/src/services/hermes.ts
  54. 4 4
      apps/insights/src/services/pyth.ts
  55. 7 0
      apps/insights/src/services/staking.ts
  56. 8 2
      apps/insights/src/static-data/price-feeds.tsx
  57. 1 0
      packages/component-library/src/AppTabs/index.module.scss
  58. 8 6
      packages/component-library/src/Card/index.module.scss
  59. 5 0
      packages/component-library/src/Drawer/index.module.scss
  60. 12 1
      packages/component-library/src/Drawer/index.tsx
  61. 23 0
      packages/component-library/src/Html/base.scss
  62. 52 8
      packages/component-library/src/Html/index.tsx
  63. 1 5
      packages/component-library/src/Link/index.module.scss
  64. 14 6
      packages/component-library/src/Skeleton/index.module.scss
  65. 2 2
      packages/component-library/src/Skeleton/index.stories.tsx
  66. 19 17
      packages/component-library/src/Skeleton/index.tsx
  67. 2 0
      packages/component-library/src/StatCard/index.module.scss
  68. 3 0
      packages/component-library/src/Table/index.module.scss
  69. 1 1
      packages/component-library/src/Table/index.tsx
  70. 48 0
      packages/component-library/src/overlay-visible-context.tsx
  71. 26 1
      packages/component-library/src/theme.scss
  72. 45 0
      pnpm-lock.yaml
  73. 2 0
      pnpm-workspace.yaml

+ 4 - 0
apps/insights/package.json

@@ -26,6 +26,7 @@
     "@pythnetwork/client": "catalog:",
     "@pythnetwork/component-library": "workspace:*",
     "@pythnetwork/fonts": "workspace:*",
+    "@pythnetwork/hermes-client": "workspace:*",
     "@pythnetwork/known-publishers": "workspace:*",
     "@pythnetwork/next-root": "workspace:*",
     "@react-hookz/web": "catalog:",
@@ -33,6 +34,7 @@
     "bs58": "catalog:",
     "clsx": "catalog:",
     "cryptocurrency-icons": "catalog:",
+    "dnum": "catalog:",
     "framer-motion": "catalog:",
     "next": "catalog:",
     "next-themes": "catalog:",
@@ -41,6 +43,7 @@
     "react-aria": "catalog:",
     "react-aria-components": "catalog:",
     "react-dom": "catalog:",
+    "recharts": "catalog:",
     "swr": "catalog:",
     "zod": "catalog:"
   },
@@ -49,6 +52,7 @@
     "@cprussin/jest-config": "catalog:",
     "@cprussin/prettier-config": "catalog:",
     "@cprussin/tsconfig": "catalog:",
+    "@pythnetwork/staking-sdk": "workspace:",
     "@svgr/webpack": "catalog:",
     "@types/jest": "catalog:",
     "@types/node": "catalog:",

+ 8 - 5
apps/insights/src/app/global-error.tsx

@@ -1,14 +1,17 @@
 "use client";
 
+import { LoggerProvider } from "@pythnetwork/app-logger/provider";
 import type { ComponentProps } from "react";
 
 import { Error } from "../components/Error";
 
 const GlobalError = (props: ComponentProps<typeof Error>) => (
-  <html lang="en" dir="ltr">
-    <body>
-      <Error {...props} />
-    </body>
-  </html>
+  <LoggerProvider>
+    <html lang="en" dir="ltr">
+      <body>
+        <Error {...props} />
+      </body>
+    </html>
+  </LoggerProvider>
 );
 export default GlobalError;

+ 0 - 1
apps/insights/src/app/publishers/layout.ts

@@ -1 +0,0 @@
-export { PublishersLayout as default } from "../../components/Publishers/layout";

+ 0 - 1
apps/insights/src/app/publishers/loading.ts

@@ -1 +0,0 @@
-export { PublishersLoading as default } from "../../components/Publishers/loading";

+ 2 - 2
apps/insights/src/app/yesterdays-prices/route.ts

@@ -1,13 +1,13 @@
 import type { NextRequest } from "next/server";
 import { z } from "zod";
 
-import { client } from "../../clickhouse";
+import { client } from "../../services/clickhouse";
 
 export async function GET(req: NextRequest) {
   const symbols = req.nextUrl.searchParams.getAll("symbols");
   const rows = await client.query({
     query:
-      "select * from insights_yesterdays_prices(symbols={symbols: Array(String)})",
+      "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})",
     query_params: { symbols },
   });
   const result = await rows.json();

+ 21 - 0
apps/insights/src/components/AsyncValue/index.tsx

@@ -0,0 +1,21 @@
+"use client";
+
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { Suspense, use } from "react";
+
+type Props<T> = {
+  placeholderWidth: number;
+  valuePromise: Promise<T>;
+};
+
+export const AsyncValue = <T,>({
+  placeholderWidth,
+  valuePromise,
+}: Props<T>) => (
+  <Suspense fallback={<Skeleton width={placeholderWidth} />}>
+    <ResolvedValue valuePromise={valuePromise} />
+  </Suspense>
+);
+
+const ResolvedValue = <T,>({ valuePromise }: Pick<Props<T>, "valuePromise">) =>
+  use(valuePromise);

+ 26 - 0
apps/insights/src/components/FormattedTokens/index.tsx

@@ -0,0 +1,26 @@
+"use client";
+
+import * as dnum from "dnum";
+import { useMemo } from "react";
+import { useLocale } from "react-aria";
+
+const DECIMALS = 6;
+
+type Props = {
+  mode?: "compact" | "wholePart" | "full";
+  children: bigint;
+};
+
+export const FormattedTokens = ({ children, mode = "compact" }: Props) => {
+  const { locale } = useLocale();
+  const value = useMemo(
+    () =>
+      dnum.format([children, DECIMALS], {
+        compact: mode === "compact",
+        locale,
+      }),
+    [children, locale, mode],
+  );
+
+  return mode === "wholePart" ? value.split(".")[0] : value;
+};

+ 0 - 7
apps/insights/src/components/H1/index.module.scss

@@ -1,7 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.h1 {
-  font-size: theme.font-size("2xl");
-  font-weight: theme.font-weight("medium");
-  margin: 0;
-}

+ 0 - 10
apps/insights/src/components/H1/index.tsx

@@ -1,10 +0,0 @@
-import clsx from "clsx";
-import type { ComponentProps } from "react";
-
-import styles from "./index.module.scss";
-
-export const H1 = ({ className, children, ...props }: ComponentProps<"h1">) => (
-  <h1 className={clsx(styles.h1, className)} {...props}>
-    {children}
-  </h1>
-);

+ 1 - 1
apps/insights/src/components/LivePrices/index.tsx

@@ -16,7 +16,7 @@ import {
 import { useNumberFormatter } from "react-aria";
 
 import styles from "./index.module.scss";
-import { client, subscribe } from "../../pyth";
+import { client, subscribe } from "../../services/pyth";
 
 export const SKELETON_WIDTH = 20;
 

+ 0 - 5
apps/insights/src/components/Loading/index.module.scss

@@ -1,5 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.loading {
-  @include theme.max-width;
-}

+ 0 - 12
apps/insights/src/components/Loading/index.tsx

@@ -1,12 +0,0 @@
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-
-import styles from "./index.module.scss";
-import { H1 } from "../H1";
-
-export const Loading = () => (
-  <div className={styles.loading}>
-    <H1>
-      <Skeleton width={60} />
-    </H1>
-  </div>
-);

+ 8 - 0
apps/insights/src/components/Overview/index.module.scss

@@ -2,4 +2,12 @@
 
 .overview {
   @include theme.max-width;
+
+  .header {
+    @include theme.h3;
+
+    color: theme.color("heading");
+    font-weight: theme.font-weight("semibold");
+    margin: theme.spacing(6) 0;
+  }
 }

+ 1 - 2
apps/insights/src/components/Overview/index.tsx

@@ -1,8 +1,7 @@
 import styles from "./index.module.scss";
-import { H1 } from "../H1";
 
 export const Overview = () => (
   <div className={styles.overview}>
-    <H1>Overview</H1>
+    <h1 className={styles.header}>Overview</h1>
   </div>
 );

+ 0 - 7
apps/insights/src/components/PriceFeeds/asset-classes-card.module.scss

@@ -1,7 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.drawerTitle {
-  display: flex;
-  flex-flow: row nowrap;
-  gap: theme.spacing(3);
-}

+ 27 - 46
apps/insights/src/components/PriceFeeds/asset-classes-card.tsx → apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx

@@ -6,35 +6,22 @@ import {
   Drawer,
   DrawerTrigger,
 } from "@pythnetwork/component-library/Drawer";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { Table } from "@pythnetwork/component-library/Table";
 import { usePathname } from "next/navigation";
-import { createSerializer } from "nuqs";
-import { Suspense, use, useMemo } from "react";
+import { type ReactNode, useMemo } from "react";
 import { useCollator } from "react-aria";
 
-import styles from "./asset-classes-card.module.scss";
-import { queryParams, useQuery } from "./use-query";
+import { serialize, useQueryParams } from "./query-params";
 
 type Props = {
-  numFeedsByAssetClassPromise: Promise<Record<string, number>>;
+  numFeedsByAssetClass: Record<string, number>;
+  children: ReactNode;
 };
 
-export const AssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => (
-  <Suspense
-    fallback={
-      <StatCard stat={<Skeleton width={10} />} {...sharedStatCardProps} />
-    }
-  >
-    <ResolvedAssetClassesCard
-      numFeedsByAssetClassPromise={numFeedsByAssetClassPromise}
-    />
-  </Suspense>
-);
-
-const ResolvedAssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => {
-  const numFeedsByAssetClass = use(numFeedsByAssetClassPromise);
+export const AssetClassesDrawer = ({
+  numFeedsByAssetClass,
+  children,
+}: Props) => {
   const numAssetClasses = useMemo(
     () => Object.keys(numFeedsByAssetClass).length,
     [numFeedsByAssetClass],
@@ -42,13 +29,13 @@ const ResolvedAssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => {
 
   return (
     <DrawerTrigger>
-      <StatCard stat={numAssetClasses} {...sharedStatCardProps} />
+      {children}
       <Drawer
         title={
-          <div className={styles.drawerTitle}>
+          <>
             <span>Asset Classes</span>
             <Badge>{numAssetClasses}</Badge>
-          </div>
+          </>
         }
       >
         {({ close }) => (
@@ -62,10 +49,6 @@ const ResolvedAssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => {
   );
 };
 
-const sharedStatCardProps = {
-  header: "Asset Classes",
-};
-
 type AssetClassTableProps = {
   numFeedsByAssetClass: Record<string, number>;
   closeDrawer: () => void;
@@ -77,28 +60,26 @@ const AssetClassTable = ({
 }: AssetClassTableProps) => {
   const collator = useCollator();
   const pathname = usePathname();
-  const { updateAssetClass } = useQuery();
+  const { updateAssetClass, updateSearch } = useQueryParams();
   const assetClassRows = useMemo(
     () =>
       Object.entries(numFeedsByAssetClass)
         .sort(([a], [b]) => collator.compare(a, b))
-        .map(([assetClass, count]) => {
-          const serialize = createSerializer(queryParams);
-          return {
-            id: assetClass,
-            href: `${pathname}${serialize({ assetClass })}`,
-            onAction: () => {
-              closeDrawer();
-              setTimeout(() => {
-                updateAssetClass(assetClass);
-              }, CLOSE_DURATION_IN_MS);
-            },
-            data: {
-              assetClass,
-              count: <Badge style="outline">{count}</Badge>,
-            },
-          };
-        }),
+        .map(([assetClass, count]) => ({
+          id: assetClass,
+          href: `${pathname}${serialize({ assetClass })}`,
+          onAction: () => {
+            closeDrawer();
+            setTimeout(() => {
+              updateAssetClass(assetClass);
+              updateSearch("");
+            }, CLOSE_DURATION_IN_MS);
+          },
+          data: {
+            assetClass,
+            count: <Badge style="outline">{count}</Badge>,
+          },
+        })),
     [numFeedsByAssetClass, collator, closeDrawer, pathname, updateAssetClass],
   );
   return (

+ 15 - 0
apps/insights/src/components/PriceFeeds/coming-soon-list.module.scss

@@ -0,0 +1,15 @@
+@use "@pythnetwork/component-library/theme";
+
+.searchBar {
+  width: 100%;
+  padding: theme.spacing(3);
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(3);
+  flex: none;
+}
+
+.priceFeeds {
+  overflow: auto;
+  flex-grow: 1;
+}

+ 10 - 48
apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.tsx → apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -1,18 +1,15 @@
 "use client";
 
-import { Badge } from "@pythnetwork/component-library/Badge";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Table } from "@pythnetwork/component-library/Table";
-import { type ReactNode, Suspense, useMemo, useState, use } from "react";
+import { type ReactNode, useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
-import styles from "./coming-soon-show-all-button.module.scss";
+import styles from "./coming-soon-list.module.scss";
 
 type Props = {
-  comingSoonPromise: Promise<ComingSoonPriceFeed[]>;
+  comingSoonFeeds: ComingSoonPriceFeed[];
 };
 
 type ComingSoonPriceFeed = {
@@ -24,44 +21,7 @@ type ComingSoonPriceFeed = {
   assetClass: ReactNode;
 };
 
-export const ComingSoonShowAllButton = ({ comingSoonPromise }: Props) => (
-  <Suspense fallback={<Button isPending {...sharedButtonProps} />}>
-    <ResolvedComingSoonShowAllButton comingSoonPromise={comingSoonPromise} />
-  </Suspense>
-);
-
-const ResolvedComingSoonShowAllButton = ({ comingSoonPromise }: Props) => {
-  const comingSoon = use(comingSoonPromise);
-
-  return (
-    <DrawerTrigger>
-      <Button {...sharedButtonProps} />
-      <Drawer
-        className={styles.comingSoonCard ?? ""}
-        title={
-          <div className={styles.drawerTitle}>
-            <span>Coming Soon</span>
-            <Badge>{comingSoon.length}</Badge>
-          </div>
-        }
-      >
-        <ComingSoonContents comingSoon={comingSoon} />
-      </Drawer>
-    </DrawerTrigger>
-  );
-};
-
-const sharedButtonProps = {
-  size: "xs" as const,
-  variant: "outline" as const,
-  children: "Show all",
-};
-
-type ComingSoonTableProps = {
-  comingSoon: ComingSoonPriceFeed[];
-};
-
-const ComingSoonContents = ({ comingSoon }: ComingSoonTableProps) => {
+export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
   const [search, setSearch] = useState("");
   const [assetClass, setAssetClass] = useState("");
   const collator = useCollator();
@@ -69,16 +29,18 @@ const ComingSoonContents = ({ comingSoon }: ComingSoonTableProps) => {
   const assetClasses = useMemo(
     () =>
       [
-        ...new Set(comingSoon.map((priceFeed) => priceFeed.assetClassAsString)),
+        ...new Set(
+          comingSoonFeeds.map((priceFeed) => priceFeed.assetClassAsString),
+        ),
       ].sort((a, b) => collator.compare(a, b)),
-    [comingSoon, collator],
+    [comingSoonFeeds, collator],
   );
   const sortedFeeds = useMemo(
     () =>
-      comingSoon.sort((a, b) =>
+      comingSoonFeeds.sort((a, b) =>
         collator.compare(a.displaySymbol, b.displaySymbol),
       ),
-    [collator, comingSoon],
+    [collator, comingSoonFeeds],
   );
   const feedsFilteredByAssetClass = useMemo(
     () =>

+ 0 - 23
apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.module.scss

@@ -1,23 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.comingSoonCard {
-  .drawerTitle {
-    display: flex;
-    flex-flow: row nowrap;
-    gap: theme.spacing(3);
-  }
-
-  .searchBar {
-    width: 100%;
-    padding: theme.spacing(3);
-    display: flex;
-    flex-flow: row nowrap;
-    gap: theme.spacing(3);
-    flex: none;
-  }
-
-  .priceFeeds {
-    overflow: auto;
-    flex-grow: 1;
-  }
-}

+ 0 - 11
apps/insights/src/components/PriceFeeds/featured-coming-soon.module.scss

@@ -1,11 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.featuredComingSoon {
-  display: flex;
-  flex-flow: row nowrap;
-  gap: theme.spacing(1);
-
-  & > * {
-    flex: 1;
-  }
-}

+ 0 - 68
apps/insights/src/components/PriceFeeds/featured-coming-soon.tsx

@@ -1,68 +0,0 @@
-import { Card } from "@pythnetwork/component-library/Card";
-import { type ReactNode, Suspense, use } from "react";
-
-import styles from "./featured-coming-soon.module.scss";
-
-type Props = {
-  placeholderPriceFeedName: ReactNode;
-  comingSoonPromise: Promise<ComingSoonPriceFeed[]>;
-};
-
-type ComingSoonPriceFeed = {
-  priceFeedName: ReactNode;
-};
-
-export const FeaturedComingSoon = ({
-  placeholderPriceFeedName,
-  comingSoonPromise,
-}: Props) => (
-  <div className={styles.featuredComingSoon}>
-    <Suspense
-      fallback={
-        <Placeholder placeholderPriceFeedName={placeholderPriceFeedName} />
-      }
-    >
-      <ResolvedFeaturedComingSoon comingSoonPromise={comingSoonPromise} />
-    </Suspense>
-  </div>
-);
-
-type PlaceholderProps = {
-  placeholderPriceFeedName: ReactNode;
-};
-
-const Placeholder = ({ placeholderPriceFeedName }: PlaceholderProps) => (
-  <>
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-  </>
-);
-
-const PlaceholderCard = ({ placeholderPriceFeedName }: PlaceholderProps) => (
-  <Card title={placeholderPriceFeedName} {...sharedCardProps} />
-);
-
-type ResolvedFeaturedComingSoonProps = {
-  comingSoonPromise: Promise<ComingSoonPriceFeed[]>;
-};
-
-const ResolvedFeaturedComingSoon = ({
-  comingSoonPromise,
-}: ResolvedFeaturedComingSoonProps) => {
-  const comingSoon = use(comingSoonPromise);
-
-  return (
-    <>
-      {comingSoon.map(({ priceFeedName }, id) => (
-        <Card key={id} title={priceFeedName} {...sharedCardProps} />
-      ))}
-    </>
-  );
-};
-
-const sharedCardProps = {
-  variant: "tertiary" as const,
-};

+ 30 - 40
apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss

@@ -1,51 +1,41 @@
 @use "@pythnetwork/component-library/theme";
 
-.featuredRecentlyAdded {
+.recentlyAddedFeed .footer {
   display: flex;
   flex-flow: row nowrap;
-  gap: theme.spacing(1);
-
-  & > * {
-    flex: 1;
-  }
-
-  .recentlyAddedFeed .footer {
-    display: flex;
-    flex-flow: row nowrap;
-    justify-content: space-between;
-    align-items: center;
-    color: theme.color("heading");
-    font-weight: theme.font-weight("medium");
-    line-height: theme.spacing(6);
-    font-size: theme.font-size("base");
-    padding: 0 theme.spacing(2);
+  justify-content: space-between;
+  align-items: center;
+  color: theme.color("heading");
+  font-weight: theme.font-weight("medium");
+  line-height: theme.spacing(6);
+  font-size: theme.font-size("base");
+  padding: 0 theme.spacing(2);
+
+  .changePercent {
+    font-size: theme.font-size("sm");
+
+    .price {
+      transition: color 100ms linear;
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(1);
+      align-items: center;
+
+      .caret {
+        width: theme.spacing(3);
+        height: theme.spacing(3);
+        transition: transform 300ms linear;
+      }
 
-    .changePercent {
-      font-size: theme.font-size("sm");
+      &[data-direction="up"] {
+        color: theme.color("states", "success", "base");
+      }
 
-      .price {
-        transition: color 100ms linear;
-        display: flex;
-        flex-flow: row nowrap;
-        gap: theme.spacing(1);
-        align-items: center;
+      &[data-direction="down"] {
+        color: theme.color("states", "error", "base");
 
         .caret {
-          width: theme.spacing(3);
-          height: theme.spacing(3);
-          transition: transform 300ms linear;
-        }
-
-        &[data-direction="up"] {
-          color: theme.color("states", "success", "base");
-        }
-
-        &[data-direction="down"] {
-          color: theme.color("states", "error", "base");
-
-          .caret {
-            transform: rotate3d(1, 0, 0, 180deg);
-          }
+          transform: rotate3d(1, 0, 0, 180deg);
         }
       }
     }

+ 11 - 79
apps/insights/src/components/PriceFeeds/featured-recently-added.tsx

@@ -3,13 +3,13 @@
 import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { type ReactNode, Suspense, use, useMemo } from "react";
+import { type ReactNode, useMemo } from "react";
 import { useNumberFormatter } from "react-aria";
 import { z } from "zod";
 
 import styles from "./featured-recently-added.module.scss";
 import { StateType, useData } from "../../use-data";
-import { SKELETON_WIDTH, LivePrice, useLivePrice } from "../LivePrices";
+import { LivePrice, useLivePrice } from "../LivePrices";
 
 const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
@@ -19,8 +19,7 @@ const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
 const CHANGE_PERCENT_SKELETON_WIDTH = 15;
 
 type Props = {
-  placeholderPriceFeedName: ReactNode;
-  recentlyAddedPromise: Promise<RecentlyAddedPriceFeed[]>;
+  recentlyAdded: RecentlyAddedPriceFeed[];
 };
 
 type RecentlyAddedPriceFeed = {
@@ -29,58 +28,7 @@ type RecentlyAddedPriceFeed = {
   priceFeedName: ReactNode;
 };
 
-export const FeaturedRecentlyAdded = ({
-  placeholderPriceFeedName,
-  recentlyAddedPromise,
-}: Props) => (
-  <div className={styles.featuredRecentlyAdded}>
-    <Suspense
-      fallback={
-        <Placeholder placeholderPriceFeedName={placeholderPriceFeedName} />
-      }
-    >
-      <ResolvedFeaturedRecentlyAdded
-        recentlyAddedPromise={recentlyAddedPromise}
-      />
-    </Suspense>
-  </div>
-);
-
-type PlaceholderProps = {
-  placeholderPriceFeedName: ReactNode;
-};
-
-const Placeholder = ({ placeholderPriceFeedName }: PlaceholderProps) => (
-  <>
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
-  </>
-);
-
-const PlaceholderCard = ({ placeholderPriceFeedName }: PlaceholderProps) => (
-  <Card
-    title={placeholderPriceFeedName}
-    footer={
-      <Footer
-        price={<Skeleton width={SKELETON_WIDTH} />}
-        changePercent={<Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />}
-      />
-    }
-    {...sharedCardProps}
-  />
-);
-
-type ResolvedFeaturedRecentlyAddedProps = {
-  recentlyAddedPromise: Promise<RecentlyAddedPriceFeed[]>;
-};
-
-const ResolvedFeaturedRecentlyAdded = ({
-  recentlyAddedPromise,
-}: ResolvedFeaturedRecentlyAddedProps) => {
-  const recentlyAdded = use(recentlyAddedPromise);
+export const FeaturedRecentlyAdded = ({ recentlyAdded }: Props) => {
   const feedKeys = useMemo(
     () => recentlyAdded.map(({ id }) => id),
     [recentlyAdded],
@@ -105,41 +53,25 @@ const ResolvedFeaturedRecentlyAdded = ({
           href="#"
           title={priceFeedName}
           footer={
-            <Footer
-              price={<LivePrice account={id} />}
-              changePercent={
+            <div className={styles.footer}>
+              <LivePrice account={id} />
+              <div className={styles.changePercent}>
                 <ChangePercent
                   yesterdaysPriceState={state}
                   feedKey={id}
                   symbol={symbol}
                 />
-              }
-            />
+              </div>
+            </div>
           }
-          {...sharedCardProps}
+          className={styles.recentlyAddedFeed ?? ""}
+          variant="tertiary"
         />
       ))}
     </>
   );
 };
 
-type FooterProps = {
-  price: ReactNode;
-  changePercent: ReactNode;
-};
-
-const Footer = ({ price, changePercent }: FooterProps) => (
-  <div className={styles.footer}>
-    {price}
-    <div className={styles.changePercent}>{changePercent}</div>
-  </div>
-);
-
-const sharedCardProps = {
-  className: styles.recentlyAddedFeed,
-  variant: "tertiary" as const,
-};
-
 const getYesterdaysPrices = async (
   symbols: string[],
 ): Promise<Record<string, number>> => {

+ 26 - 5
apps/insights/src/components/PriceFeeds/index.module.scss

@@ -16,6 +16,16 @@
     flex-flow: column nowrap;
     gap: theme.spacing(6);
 
+    .featuredFeeds {
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(1);
+
+      & > * {
+        flex: 1;
+      }
+    }
+
     .stats {
       display: flex;
       flex-flow: row nowrap;
@@ -26,20 +36,27 @@
         flex: 1;
       }
     }
+
+    .priceFeedId {
+      color: theme.color("link", "normal");
+      font-weight: theme.font-weight("medium");
+    }
   }
 }
 
 .priceFeedNameAndIcon,
-.priceFeedNameAndAssetClass {
+.priceFeedNameAndDescription {
   display: flex;
   flex-flow: row nowrap;
   gap: theme.spacing(3);
+  align-items: center;
 
   .priceFeedName {
     display: flex;
     flex-flow: row nowrap;
     align-items: center;
     gap: theme.spacing(1);
+    color: theme.color("heading");
 
     .firstPart {
       font-weight: theme.font-weight("medium");
@@ -47,7 +64,7 @@
 
     .divider {
       font-weight: theme.font-weight("light");
-      color: theme.color("heading");
+      color: theme.color("muted");
     }
 
     .part {
@@ -59,20 +76,24 @@
 .priceFeedNameAndIcon .priceFeedIcon {
   width: theme.spacing(6);
   height: theme.spacing(6);
+
+  &.skeleton {
+    border-radius: theme.border-radius("full");
+  }
 }
 
-.priceFeedNameAndAssetClass {
+.priceFeedNameAndDescription {
   .priceFeedIcon {
     width: theme.spacing(10);
     height: theme.spacing(10);
   }
 
-  .nameAndClass {
+  .nameAndDescription {
     display: flex;
     flex-flow: column nowrap;
     gap: theme.spacing(1);
 
-    .assetClass {
+    .description {
       font-size: theme.font-size("xs");
       font-weight: theme.font-weight("medium");
       line-height: theme.spacing(4);

+ 108 - 114
apps/insights/src/components/PriceFeeds/index.tsx

@@ -1,30 +1,42 @@
 import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown";
 import { StackPlus } from "@phosphor-icons/react/dist/ssr/StackPlus";
 import { Badge } from "@pythnetwork/component-library/Badge";
+import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
+import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import base58 from "bs58";
+import clsx from "clsx";
 import Generic from "cryptocurrency-icons/svg/color/generic.svg";
 import { Fragment } from "react";
 import { z } from "zod";
 
-import { AssetClassesCard } from "./asset-classes-card";
-import { ComingSoonShowAllButton } from "./coming-soon-show-all-button";
-import { FeaturedComingSoon } from "./featured-coming-soon";
+import { AssetClassesDrawer } from "./asset-classes-drawer";
+import { ComingSoonList } from "./coming-soon-list";
 import { FeaturedRecentlyAdded } from "./featured-recently-added";
 import styles from "./index.module.scss";
-import { NumActiveFeeds } from "./num-active-feeds";
 import { PriceFeedsCard } from "./price-feeds-card";
 import { getIcon } from "../../icons";
-import { client } from "../../pyth";
+import { client } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { CopyButton } from "../CopyButton";
 
 const PRICE_FEEDS_ANCHOR = "priceFeeds";
 
-export const PriceFeeds = () => {
-  const priceFeeds = getPriceFeeds();
+export const PriceFeeds = async () => {
+  const priceFeeds = await getPriceFeeds();
+  const numFeedsByAssetClass = getNumFeedsByAssetClass(priceFeeds.activeFeeds);
+  const featuredComingSoon = [
+    ...filterFeeds(
+      priceFeeds.comingSoon,
+      priceFeedsStaticConfig.featuredComingSoon,
+    ),
+    ...priceFeeds.comingSoon.filter(
+      ({ symbol }) =>
+        !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
+    ),
+  ].slice(0, 5);
 
   return (
     <div className={styles.priceFeeds}>
@@ -34,13 +46,7 @@ export const PriceFeeds = () => {
           <StatCard
             variant="primary"
             header="Active Feeds"
-            stat={
-              <NumActiveFeeds
-                numFeedsPromise={priceFeeds.then(
-                  ({ activeFeeds }) => activeFeeds.length,
-                )}
-              />
-            }
+            stat={priceFeeds.activeFeeds.length}
             href={`#${PRICE_FEEDS_ANCHOR}`}
           />
           <StatCard
@@ -53,90 +59,101 @@ export const PriceFeeds = () => {
             href="https://docs.pyth.network/price-feeds/contract-addresses"
             target="_blank"
           />
-          <AssetClassesCard
-            numFeedsByAssetClassPromise={priceFeeds.then(({ activeFeeds }) =>
-              getNumFeedsByAssetClass(activeFeeds),
-            )}
-          />
+          <AssetClassesDrawer numFeedsByAssetClass={numFeedsByAssetClass}>
+            <StatCard
+              header="Asset Classes"
+              stat={Object.keys(numFeedsByAssetClass).length}
+            />
+          </AssetClassesDrawer>
         </div>
         <Card title="Recently added" icon={<StackPlus />}>
-          <FeaturedRecentlyAdded
-            placeholderPriceFeedName={<PlaceholderPriceFeedNameAndAssetClass />}
-            recentlyAddedPromise={priceFeeds.then(({ activeFeeds }) =>
-              filterFeeds(
-                activeFeeds,
+          <div className={styles.featuredFeeds}>
+            <FeaturedRecentlyAdded
+              recentlyAdded={filterFeeds(
+                priceFeeds.activeFeeds,
                 priceFeedsStaticConfig.featuredRecentlyAdded,
               ).map(({ product, symbol }) => ({
                 id: product.price_account,
                 symbol,
                 priceFeedName: (
-                  <PriceFeedNameAndAssetClass
-                    assetClass={product.asset_type.toUpperCase()}
-                  >
+                  <PriceNameAndDescription description={product.description}>
                     {product.display_symbol}
-                  </PriceFeedNameAndAssetClass>
+                  </PriceNameAndDescription>
                 ),
-              })),
-            )}
-          />
+              }))}
+            />
+          </div>
         </Card>
         <Card
           title="Coming soon"
           icon={<ClockCountdown />}
           toolbar={
-            <ComingSoonShowAllButton
-              comingSoonPromise={priceFeeds.then(({ comingSoon }) =>
-                comingSoon.map(({ symbol, product }) => ({
-                  symbol,
-                  id: product.price_account,
-                  displaySymbol: product.display_symbol,
-                  assetClassAsString: product.asset_type,
-                  priceFeedName: (
-                    <PriceFeedNameAndIcon>
-                      {product.display_symbol}
-                    </PriceFeedNameAndIcon>
-                  ),
-                  assetClass: (
-                    <Badge variant="neutral" style="outline" size="xs">
-                      {product.asset_type.toUpperCase()}
-                    </Badge>
-                  ),
-                })),
-              )}
-            />
+            <DrawerTrigger>
+              <Button size="xs" variant="outline">
+                Show all
+              </Button>
+              <Drawer
+                className={styles.comingSoonCard ?? ""}
+                title={
+                  <>
+                    <span>Coming Soon</span>
+                    <Badge>{priceFeeds.comingSoon.length}</Badge>
+                  </>
+                }
+              >
+                <ComingSoonList
+                  comingSoonFeeds={priceFeeds.comingSoon.map(
+                    ({ symbol, product }) => ({
+                      symbol,
+                      id: product.price_account,
+                      displaySymbol: product.display_symbol,
+                      assetClassAsString: product.asset_type,
+                      priceFeedName: (
+                        <PriceFeedNameAndIcon>
+                          {product.display_symbol}
+                        </PriceFeedNameAndIcon>
+                      ),
+                      assetClass: (
+                        <Badge variant="neutral" style="outline" size="xs">
+                          {product.asset_type.toUpperCase()}
+                        </Badge>
+                      ),
+                    }),
+                  )}
+                />
+              </Drawer>
+            </DrawerTrigger>
           }
         >
-          <FeaturedComingSoon
-            placeholderPriceFeedName={<PlaceholderPriceFeedNameAndAssetClass />}
-            comingSoonPromise={priceFeeds.then(({ comingSoon }) =>
-              [
-                ...filterFeeds(
-                  comingSoon,
-                  priceFeedsStaticConfig.featuredComingSoon,
-                ),
-                ...comingSoon.filter(
-                  ({ symbol }) =>
-                    !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
-                ),
-              ]
-                .slice(0, 5)
-                .map(({ product }) => ({
-                  priceFeedName: (
-                    <PriceFeedNameAndAssetClass
-                      assetClass={product.asset_type.toUpperCase()}
-                    >
-                      {product.display_symbol}
-                    </PriceFeedNameAndAssetClass>
-                  ),
-                })),
-            )}
-          />
+          <div className={styles.featuredFeeds}>
+            {featuredComingSoon.map(({ product }, id) => (
+              <Card
+                key={id}
+                title={
+                  <PriceNameAndDescription description={product.description}>
+                    {product.display_symbol}
+                  </PriceNameAndDescription>
+                }
+                variant="tertiary"
+              />
+            ))}
+          </div>
         </Card>
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
-          placeholderPriceFeedName={<PlaceholderPriceFeedNameAndIcon />}
-          priceFeedsPromise={priceFeeds.then(({ activeFeeds }) =>
-            activeFeeds.map(({ symbol, product, price }) => ({
+          nameLoadingSkeleton={
+            <div className={styles.priceFeedNameAndIcon}>
+              <Skeleton
+                className={clsx(styles.priceFeedIcon, styles.skeleton)}
+                fill
+              />
+              <div className={styles.priceFeedName}>
+                <Skeleton width={20} />
+              </div>
+            </div>
+          }
+          priceFeeds={priceFeeds.activeFeeds.map(
+            ({ symbol, product, price }) => ({
               symbol,
               id: product.price_account,
               displaySymbol: product.display_symbol,
@@ -155,11 +172,14 @@ export const PriceFeeds = () => {
                 </Badge>
               ),
               priceFeedId: (
-                <CopyButton text={toHex(product.price_account)}>
+                <CopyButton
+                  className={styles.priceFeedId ?? ""}
+                  text={toHex(product.price_account)}
+                >
                   {toTruncatedHex(product.price_account)}
                 </CopyButton>
               ),
-            })),
+            }),
           )}
         />
       </div>
@@ -167,34 +187,18 @@ export const PriceFeeds = () => {
   );
 };
 
-const PriceFeedNameAndAssetClass = ({
+const PriceNameAndDescription = ({
   children,
-  assetClass,
+  description,
 }: {
   children: string;
-  assetClass: string;
+  description: string;
 }) => (
-  <div className={styles.priceFeedNameAndAssetClass}>
+  <div className={styles.priceFeedNameAndDescription}>
     <PriceFeedIcon>{children}</PriceFeedIcon>
-    <div className={styles.nameAndClass}>
+    <div className={styles.nameAndDescription}>
       <PriceFeedName>{children}</PriceFeedName>
-      <div className={styles.assetClass}>{assetClass}</div>
-    </div>
-  </div>
-);
-
-const PlaceholderPriceFeedNameAndAssetClass = () => (
-  <div className={styles.priceFeedNameAndAssetClass}>
-    <div className={styles.priceFeedIcon}>
-      <Skeleton round />
-    </div>
-    <div className={styles.nameAndClass}>
-      <div className={styles.priceFeedName}>
-        <Skeleton width={20} />
-      </div>
-      <div className={styles.assetClass}>
-        <Skeleton width={10} />
-      </div>
+      <div className={styles.description}>{description.split("/")[0]}</div>
     </div>
   </div>
 );
@@ -206,17 +210,6 @@ const PriceFeedNameAndIcon = ({ children }: { children: string }) => (
   </div>
 );
 
-const PlaceholderPriceFeedNameAndIcon = () => (
-  <div className={styles.priceFeedNameAndIcon}>
-    <div className={styles.priceFeedIcon}>
-      <Skeleton round />
-    </div>
-    <div className={styles.priceFeedName}>
-      <Skeleton width={20} />
-    </div>
-  </div>
-);
-
 const PriceFeedIcon = ({ children }: { children: string }) => {
   const firstPart = children.split("/")[0];
   const Icon = firstPart ? (getIcon(firstPart) ?? Generic) : Generic;
@@ -304,6 +297,7 @@ const priceFeedsSchema = z.array(
     product: z.object({
       display_symbol: z.string(),
       asset_type: z.string(),
+      description: z.string(),
       price_account: z.string(),
       weekly_schedule: z.string().optional(),
     }),

+ 0 - 17
apps/insights/src/components/PriceFeeds/num-active-feeds.tsx

@@ -1,17 +0,0 @@
-"use client";
-
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { Suspense, use } from "react";
-
-type Props = {
-  numFeedsPromise: Promise<number>;
-};
-
-export const NumActiveFeeds = ({ numFeedsPromise }: Props) => (
-  <Suspense fallback={<Skeleton width={10} />}>
-    <ResolvedNumActiveFeeds numFeedsPromise={numFeedsPromise} />
-  </Suspense>
-);
-
-const ResolvedNumActiveFeeds = ({ numFeedsPromise }: Props) =>
-  use(numFeedsPromise);

+ 0 - 9
apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss

@@ -1,9 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.priceFeedsCard {
-  .toolbar {
-    display: flex;
-    flex-flow: row nowrap;
-    gap: theme.spacing(2);
-  }
-}

+ 235 - 265
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -2,27 +2,22 @@
 
 import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
 import { Badge } from "@pythnetwork/component-library/Badge";
-import {
-  type Props as CardProps,
-  Card,
-} from "@pythnetwork/component-library/Card";
+import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
-import { type ColumnConfig, Table } from "@pythnetwork/component-library/Table";
-import clsx from "clsx";
+import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
 import { usePathname } from "next/navigation";
-import { createSerializer } from "nuqs";
-import { type ReactNode, Suspense, use, useCallback, useMemo } from "react";
+import { type ReactNode, Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
-import styles from "./price-feeds-card.module.scss";
-import { queryParams, useQuery } from "./use-query";
+import { serialize, useQueryParams } from "./query-params";
 import { SKELETON_WIDTH, LivePrice, LiveConfidence } from "../LivePrices";
 
-type Props = Omit<CardProps<"div">, "icon" | "title" | "toolbar" | "footer"> & {
-  priceFeedsPromise: Promise<PriceFeed[]>;
-  placeholderPriceFeedName: ReactNode;
+type Props = {
+  id: string;
+  nameLoadingSkeleton: ReactNode;
+  priceFeeds: PriceFeed[];
 };
 
 type PriceFeed = {
@@ -32,299 +27,274 @@ type PriceFeed = {
   assetClassAsString: string;
   exponent: number;
   numPublishers: number;
-  weeklySchedule: string | undefined;
   priceFeedId: ReactNode;
   priceFeedName: ReactNode;
   assetClass: ReactNode;
 };
 
-export const PriceFeedsCard = ({
-  priceFeedsPromise,
-  className,
-  placeholderPriceFeedName,
-  ...props
-}: Props) => (
-  <Card
-    className={clsx(className, styles.priceFeedsCard)}
-    icon={<ChartLine />}
-    title={
-      <>
-        <span>Price Feeds</span>
-        <Suspense>
-          <Badge style="filled" variant="neutral" size="md">
-            <NumFeeds priceFeedsPromise={priceFeedsPromise} />
-          </Badge>
-        </Suspense>
-      </>
-    }
-    toolbar={
-      <div className={styles.toolbar ?? ""}>
-        <Suspense
-          fallback={
-            <>
-              <Select
-                isPending
-                options={[]}
-                buttonLabel="Asset Class"
-                {...assetClassSelectProps}
-              />
-              <SearchInput isPending isDisabled {...searchInputProps} />
-            </>
-          }
-        >
-          <ToolbarContents priceFeedsPromise={priceFeedsPromise} />
-        </Suspense>
-      </div>
-    }
-    footer={
-      <Suspense>
-        <Footer priceFeedsPromise={priceFeedsPromise} />
-      </Suspense>
-    }
-    {...props}
-  >
-    <Suspense
-      fallback={
-        <Table isLoading {...sharedTableProps(placeholderPriceFeedName)} />
-      }
-    >
-      <Results priceFeedsPromise={priceFeedsPromise} />
-    </Suspense>
-  </Card>
+export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
+  <Suspense fallback={<PriceFeedsCardContents isLoading {...props} />}>
+    <ResolvedPriceFeedsCard priceFeeds={priceFeeds} {...props} />
+  </Suspense>
 );
 
-type NumFeedsProps = {
-  priceFeedsPromise: Props["priceFeedsPromise"];
-};
-
-const NumFeeds = ({ priceFeedsPromise }: NumFeedsProps) =>
-  useFilteredFeeds(priceFeedsPromise).length;
+const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
+  const {
+    search,
+    page,
+    pageSize,
+    assetClass,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    updateAssetClass,
+  } = useQueryParams();
 
-type ToolbarProps = {
-  priceFeedsPromise: Props["priceFeedsPromise"];
-};
-
-const ToolbarContents = ({ priceFeedsPromise }: ToolbarProps) => {
-  const { search, assetClass, updateSearch, updateAssetClass } = useQuery();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
   const collator = useCollator();
-  const priceFeeds = use(priceFeedsPromise);
-  const assetClasses = useMemo(
+  const sortedFeeds = useMemo(
     () =>
-      [...new Set(priceFeeds.map((feed) => feed.assetClassAsString))].sort(
-        (a, b) => collator.compare(a, b),
+      priceFeeds.sort((a, b) =>
+        collator.compare(a.displaySymbol, b.displaySymbol),
       ),
     [priceFeeds, collator],
   );
-
-  return (
-    <>
-      <Select
-        optionGroups={[
-          { name: "All", options: [""] },
-          { name: "Asset classes", options: assetClasses },
-        ]}
-        hideGroupLabel
-        show={(value) => (value === "" ? "All" : value)}
-        placement="bottom end"
-        buttonLabel={assetClass === "" ? "Asset Class" : assetClass}
-        selectedKey={assetClass}
-        onSelectionChange={updateAssetClass}
-        {...assetClassSelectProps}
-      />
-      <SearchInput
-        defaultValue={search}
-        onChange={updateSearch}
-        {...searchInputProps}
-      />
-    </>
+  const feedsFilteredByAssetClass = useMemo(
+    () =>
+      assetClass
+        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
+        : sortedFeeds,
+    [assetClass, sortedFeeds],
   );
-};
-
-const assetClassSelectProps = {
-  label: "Asset Class",
-  size: "sm" as const,
-  variant: "outline" as const,
-  hideLabel: true,
-};
-
-const searchInputProps = {
-  size: "sm" as const,
-  width: 40,
-};
-
-const Results = ({
-  priceFeedsPromise,
-}: {
-  priceFeedsPromise: Props["priceFeedsPromise"];
-}) => {
-  const { page, pageSize } = useQuery();
-  const filteredFeeds = useFilteredFeeds(priceFeedsPromise);
+  const filteredFeeds = useMemo(() => {
+    if (search === "") {
+      return feedsFilteredByAssetClass;
+    } else {
+      const searchTokens = search
+        .split(" ")
+        .flatMap((item) => item.split(","))
+        .filter(Boolean);
+      return feedsFilteredByAssetClass.filter((feed) =>
+        searchTokens.some((token) => filter.contains(feed.symbol, token)),
+      );
+    }
+  }, [search, feedsFilteredByAssetClass, filter]);
   const paginatedFeeds = useMemo(
     () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize),
     [page, pageSize, filteredFeeds],
   );
   const rows = useMemo(
     () =>
-      paginatedFeeds.map(
-        ({
-          id,
-          priceFeedName,
-          assetClass,
-          priceFeedId,
-          exponent,
-          numPublishers,
-          weeklySchedule,
-        }) => ({
-          id,
-          href: "/",
-          data: {
-            priceFeedName,
-            assetClass,
-            priceFeedId,
-            price: <LivePrice account={id} />,
-            confidenceInterval: <LiveConfidence account={id} />,
-            exponent,
-            numPublishers,
-            weeklySchedule,
-          },
-        }),
-      ),
+      paginatedFeeds.map(({ id, ...data }) => ({
+        id,
+        href: "#",
+        data: {
+          ...data,
+          price: <LivePrice account={id} />,
+          confidenceInterval: <LiveConfidence account={id} />,
+        },
+      })),
     [paginatedFeeds],
   );
 
-  return (
-    <Table
-      rows={rows}
-      renderEmptyState={() => <p>No results!</p>}
-      {...sharedTableProps()}
-    />
-  );
-};
-
-const sharedTableProps = (placeholderPriceFeedName?: ReactNode) => ({
-  label: "Price Feeds",
-  columns: [
-    {
-      id: "priceFeedName",
-      name: "PRICE FEED",
-      isRowHeader: true,
-      alignment: "left",
-      width: 50,
-      loadingSkeleton: placeholderPriceFeedName,
-    },
-    {
-      id: "assetClass",
-      name: "ASSET CLASS",
-      alignment: "left",
-      width: 60,
-    },
-    {
-      id: "priceFeedId",
-      name: "PRICE FEED ID",
-      alignment: "left",
-      width: 40,
-    },
-    {
-      id: "price",
-      name: "PRICE",
-      alignment: "right",
-      width: 40,
-      loadingSkeletonWidth: SKELETON_WIDTH,
-    },
-    {
-      id: "confidenceInterval",
-      name: "CONFIDENCE INTERVAL",
-      alignment: "left",
-      width: 40,
-      loadingSkeletonWidth: SKELETON_WIDTH,
-    },
-    {
-      id: "exponent",
-      name: "EXPONENT",
-      alignment: "left",
-      width: 8,
-    },
-    {
-      id: "numPublishers",
-      name: "# PUBLISHERS",
-      alignment: "left",
-      width: 8,
-    },
-    {
-      id: "weeklySchedule",
-      name: "WEEKLY SCHEDULE",
-      alignment: "left",
-      width: 100,
-    },
-  ] as const satisfies ColumnConfig<string>[],
-  rounded: true,
-  fill: true,
-});
-
-const Footer = ({
-  priceFeedsPromise,
-}: {
-  priceFeedsPromise: Props["priceFeedsPromise"];
-}) => {
-  const { page, pageSize, updatePage, updatePageSize } = useQuery();
-  const filteredFeeds = useFilteredFeeds(priceFeedsPromise);
-
   const numPages = useMemo(
     () => Math.ceil(filteredFeeds.length / pageSize),
-    [filteredFeeds, pageSize],
+    [filteredFeeds.length, pageSize],
   );
 
   const pathname = usePathname();
 
   const mkPageLink = useCallback(
-    (page: number) => {
-      const serialize = createSerializer(queryParams);
-      return `${pathname}${serialize({ page, pageSize })}`;
-    },
+    (page: number) => `${pathname}${serialize({ page, pageSize })}`,
     [pathname, pageSize],
   );
 
+  const assetClasses = useMemo(
+    () =>
+      [...new Set(priceFeeds.map((feed) => feed.assetClassAsString))].sort(
+        (a, b) => collator.compare(a, b),
+      ),
+    [priceFeeds, collator],
+  );
+
   return (
-    <Paginator
+    <PriceFeedsCardContents
+      numResults={filteredFeeds.length}
+      search={search}
+      assetClass={assetClass}
+      assetClasses={assetClasses}
       numPages={numPages}
-      currentPage={page}
-      onPageChange={updatePage}
+      page={page}
       pageSize={pageSize}
+      onSearchChange={updateSearch}
+      onAssetClassChange={updateAssetClass}
       onPageSizeChange={updatePageSize}
-      pageSizeOptions={[10, 20, 30, 40, 50]}
+      onPageChange={updatePage}
       mkPageLink={mkPageLink}
+      rows={rows}
+      {...props}
     />
   );
 };
 
-const useFilteredFeeds = (priceFeedsPromise: Promise<PriceFeed[]>) => {
-  const { search, assetClass } = useQuery();
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const collator = useCollator();
-  const activeFeeds = use(priceFeedsPromise);
-  const sortedFeeds = useMemo(
-    () =>
-      activeFeeds.sort((a, b) =>
-        collator.compare(a.displaySymbol, b.displaySymbol),
-      ),
-    [activeFeeds, collator],
-  );
-  const feedsFilteredByAssetClass = useMemo(
-    () =>
-      assetClass
-        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
-        : sortedFeeds,
-    [assetClass, sortedFeeds],
-  );
-  const filteredFeeds = useMemo(
-    () =>
-      search === ""
-        ? feedsFilteredByAssetClass
-        : feedsFilteredByAssetClass.filter((feed) =>
-            filter.contains(feed.symbol, search),
-          ),
-    [search, feedsFilteredByAssetClass, filter],
+type PriceFeedsCardContents = Pick<Props, "id" | "nameLoadingSkeleton"> &
+  (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        numResults: number;
+        search: string;
+        assetClass: string;
+        assetClasses: string[];
+        numPages: number;
+        page: number;
+        pageSize: number;
+        onSearchChange: (newSearch: string) => void;
+        onAssetClassChange: (newAssetClass: string) => void;
+        onPageSizeChange: (newPageSize: number) => void;
+        onPageChange: (newPage: number) => void;
+        mkPageLink: (page: number) => string;
+        rows: RowConfig<
+          | "priceFeedName"
+          | "assetClass"
+          | "priceFeedId"
+          | "price"
+          | "confidenceInterval"
+          | "exponent"
+          | "numPublishers"
+        >[];
+      }
   );
 
-  return filteredFeeds;
-};
+const PriceFeedsCardContents = ({
+  id,
+  nameLoadingSkeleton,
+  ...props
+}: PriceFeedsCardContents) => (
+  <Card
+    id={id}
+    icon={<ChartLine />}
+    title={
+      <>
+        <span>Price Feeds</span>
+        {!props.isLoading && (
+          <Badge style="filled" variant="neutral" size="md">
+            {props.numResults}
+          </Badge>
+        )}
+      </>
+    }
+    toolbar={
+      <>
+        <Select<string>
+          label="Asset Class"
+          size="sm"
+          variant="outline"
+          hideLabel
+          {...(props.isLoading
+            ? { isPending: true, options: [], buttonLabel: "Asset Class" }
+            : {
+                optionGroups: [
+                  { name: "All", options: [""] },
+                  { name: "Asset classes", options: props.assetClasses },
+                ],
+                hideGroupLabel: true,
+                show: (value) => (value === "" ? "All" : value),
+                placement: "bottom end",
+                buttonLabel:
+                  props.assetClass === "" ? "Asset Class" : props.assetClass,
+                selectedKey: props.assetClass,
+                onSelectionChange: props.onAssetClassChange,
+              })}
+        />
+        <SearchInput
+          size="sm"
+          width={40}
+          {...(props.isLoading
+            ? { isPending: true, isDisabled: true }
+            : {
+                defaultValue: props.search,
+                onChange: props.onSearchChange,
+              })}
+        />
+      </>
+    }
+    {...(!props.isLoading && {
+      footer: (
+        <Paginator
+          numPages={props.numPages}
+          currentPage={props.page}
+          onPageChange={props.onPageChange}
+          pageSize={props.pageSize}
+          onPageSizeChange={props.onPageSizeChange}
+          pageSizeOptions={[10, 20, 30, 40, 50]}
+          mkPageLink={props.mkPageLink}
+        />
+      ),
+    })}
+  >
+    <Table
+      rounded
+      fill
+      label="Price Feeds"
+      columns={[
+        {
+          id: "priceFeedName",
+          name: "PRICE FEED",
+          isRowHeader: true,
+          alignment: "left",
+          width: 50,
+          loadingSkeleton: nameLoadingSkeleton,
+        },
+        {
+          id: "assetClass",
+          name: "ASSET CLASS",
+          alignment: "left",
+          width: 60,
+          loadingSkeletonWidth: 20,
+        },
+        {
+          id: "priceFeedId",
+          name: "PRICE FEED ID",
+          alignment: "left",
+          width: 40,
+          loadingSkeletonWidth: 30,
+        },
+        {
+          id: "price",
+          name: "PRICE",
+          alignment: "right",
+          width: 40,
+          loadingSkeletonWidth: SKELETON_WIDTH,
+        },
+        {
+          id: "confidenceInterval",
+          name: "CONFIDENCE INTERVAL",
+          alignment: "left",
+          width: 40,
+          loadingSkeletonWidth: SKELETON_WIDTH,
+        },
+        {
+          id: "exponent",
+          name: "EXPONENT",
+          alignment: "left",
+          width: 8,
+        },
+        {
+          id: "numPublishers",
+          name: "# PUBLISHERS",
+          alignment: "left",
+          width: 8,
+        },
+      ]}
+      {...(props.isLoading
+        ? {
+            isLoading: true,
+          }
+        : {
+            rows: props.rows,
+            renderEmptyState: () => <p>No results!</p>,
+          })}
+    />
+  </Card>
+);

+ 16 - 9
apps/insights/src/components/PriceFeeds/use-query.ts → apps/insights/src/components/PriceFeeds/query-params.ts

@@ -1,8 +1,22 @@
 import { useLogger } from "@pythnetwork/app-logger";
-import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
+import {
+  parseAsString,
+  parseAsInteger,
+  useQueryStates,
+  createSerializer,
+} from "nuqs";
 import { useCallback } from "react";
 
-export const useQuery = () => {
+const queryParams = {
+  assetClass: parseAsString.withDefault(""),
+  page: parseAsInteger.withDefault(1),
+  pageSize: parseAsInteger.withDefault(30),
+  search: parseAsString.withDefault(""),
+};
+
+export const serialize = createSerializer(queryParams);
+
+export const useQueryParams = () => {
   const logger = useLogger();
 
   const [{ search, page, pageSize, assetClass }, setQuery] =
@@ -56,10 +70,3 @@ export const useQuery = () => {
     updateAssetClass,
   };
 };
-
-export const queryParams = {
-  assetClass: parseAsString.withDefault(""),
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(30),
-  search: parseAsString.withDefault(""),
-};

+ 168 - 0
apps/insights/src/components/PriceFeeds/use-filtered-price-feeds.tsx

@@ -0,0 +1,168 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
+import {
+  type ReactNode,
+  type ComponentProps,
+  Suspense,
+  createContext,
+  useCallback,
+  useMemo,
+  use,
+} from "react";
+import { useFilter, useCollator } from "react-aria";
+
+export const queryParams = {
+  assetClass: parseAsString.withDefault(""),
+  page: parseAsInteger.withDefault(1),
+  pageSize: parseAsInteger.withDefault(30),
+  search: parseAsString.withDefault(""),
+};
+
+const FilteredPriceFeedsContext = createContext<
+  undefined | ReturnType<typeof useFilteredPriceFeedsContext>
+>(undefined);
+
+type FilteredPriceFeedsProviderProps = Omit<
+  ComponentProps<typeof FilteredPriceFeedsContext>,
+  "value"
+> & {
+  priceFeeds: PriceFeed[];
+};
+
+type PriceFeed = {
+  symbol: string;
+  id: string;
+  displaySymbol: string;
+  assetClassAsString: string;
+  exponent: number;
+  numPublishers: number;
+  priceFeedId: ReactNode;
+  priceFeedName: ReactNode;
+  assetClass: ReactNode;
+};
+
+export const FilteredPriceFeedsProvider = (
+  props: FilteredPriceFeedsProviderProps,
+) => (
+  <Suspense>
+    <ResolvedFilteredPriceFeedsProvider {...props} />
+  </Suspense>
+);
+
+const ResolvedFilteredPriceFeedsProvider = ({
+  priceFeeds,
+  ...props
+}: FilteredPriceFeedsProviderProps) => {
+  const value = useFilteredPriceFeedsContext(priceFeeds);
+
+  return <FilteredPriceFeedsContext value={value} {...props} />;
+};
+
+export const useFilteredPriceFeedsContext = (priceFeeds: PriceFeed[]) => {
+  const logger = useLogger();
+
+  const [{ search, page, pageSize, assetClass }, setQuery] =
+    useQueryStates(queryParams);
+
+  const updateQuery = useCallback(
+    (...params: Parameters<typeof setQuery>) => {
+      setQuery(...params).catch((error: unknown) => {
+        logger.error("Failed to update query", error);
+      });
+    },
+    [setQuery, logger],
+  );
+
+  const updateSearch = useCallback(
+    (newSearch: string) => {
+      updateQuery({ page: 1, search: newSearch });
+    },
+    [updateQuery],
+  );
+
+  const updatePage = useCallback(
+    (newPage: number) => {
+      updateQuery({ page: newPage });
+    },
+    [updateQuery],
+  );
+
+  const updatePageSize = useCallback(
+    (newPageSize: number) => {
+      updateQuery({ page: 1, pageSize: newPageSize });
+    },
+    [updateQuery],
+  );
+
+  const updateAssetClass = useCallback(
+    (newAssetClass: string) => {
+      updateQuery({ page: 1, assetClass: newAssetClass });
+    },
+    [updateQuery],
+  );
+
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const collator = useCollator();
+  const sortedFeeds = useMemo(
+    () =>
+      priceFeeds.sort((a, b) =>
+        collator.compare(a.displaySymbol, b.displaySymbol),
+      ),
+    [priceFeeds, collator],
+  );
+  const feedsFilteredByAssetClass = useMemo(
+    () =>
+      assetClass
+        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
+        : sortedFeeds,
+    [assetClass, sortedFeeds],
+  );
+  const filteredFeeds = useMemo(() => {
+    if (search === "") {
+      return feedsFilteredByAssetClass;
+    } else {
+      const searchTokens = search
+        .split(" ")
+        .flatMap((item) => item.split(","))
+        .filter(Boolean);
+      return feedsFilteredByAssetClass.filter((feed) =>
+        searchTokens.some((token) => filter.contains(feed.symbol, token)),
+      );
+    }
+  }, [search, feedsFilteredByAssetClass, filter]);
+  const paginatedFeeds = useMemo(
+    () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize),
+    [page, pageSize, filteredFeeds],
+  );
+
+  return {
+    filteredFeeds,
+    paginatedFeeds,
+    search,
+    page,
+    pageSize,
+    assetClass,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    updateAssetClass,
+  };
+};
+
+export const useFilteredPriceFeeds = () => {
+  const value = use(FilteredPriceFeedsContext);
+  if (value) {
+    return value;
+  } else {
+    throw new FilteredPriceFeedsNotInitializedError();
+  }
+};
+
+class FilteredPriceFeedsNotInitializedError extends Error {
+  constructor() {
+    super("This component must be a child of <FilteredPriceFeedsProvider>");
+    this.name = "FilteredPriceFeedsNotInitializedError";
+  }
+}

+ 0 - 30
apps/insights/src/components/Publishers/columns.ts

@@ -1,30 +0,0 @@
-import type { ColumnConfig } from "@pythnetwork/component-library/Table";
-
-export const columns = [
-  { id: "rank", name: "RANKING", loadingSkeletonWidth: 10 },
-  {
-    id: "name",
-    name: "NAME / ID",
-    isRowHeader: true,
-    fill: true,
-    alignment: "left",
-    loadingSkeletonWidth: 48,
-  },
-  {
-    id: "activeFeeds",
-    name: "ACTIVE FEEDS",
-    alignment: "center",
-    loadingSkeletonWidth: 6,
-  },
-  {
-    id: "inactiveFeeds",
-    name: "INACTIVE FEEDS",
-    alignment: "center",
-    loadingSkeletonWidth: 6,
-  },
-  {
-    id: "score",
-    name: "SCORE",
-    loadingSkeletonWidth: 6,
-  },
-] satisfies ColumnConfig<string>[];

+ 0 - 8
apps/insights/src/components/Publishers/epoch-select.module.scss

@@ -1,8 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.epochSelect {
-  display: flex;
-  flex-flow: row nowrap;
-  align-items: center;
-  gap: theme.spacing(2);
-}

+ 0 - 38
apps/insights/src/components/Publishers/epoch-select.tsx

@@ -1,38 +0,0 @@
-"use client";
-
-import { CalendarDots } from "@phosphor-icons/react/dist/ssr/CalendarDots";
-import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
-import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Select } from "@pythnetwork/component-library/Select";
-
-import styles from "./epoch-select.module.scss";
-
-export const EpochSelect = () => (
-  <div className={styles.epochSelect}>
-    <Button variant="outline" size="sm" beforeIcon={CaretLeft} hideText>
-      Previous Epoch
-    </Button>
-    <Select
-      variant="outline"
-      size="sm"
-      icon={CalendarDots}
-      options={["27 Oct – 3 Nov"]}
-      selectedKey="27 Oct – 3 Nov"
-      label="Epoch"
-      hideLabel
-      onSelectionChange={() => {
-        /* no-op */
-      }}
-    />
-    <Button
-      variant="outline"
-      size="sm"
-      beforeIcon={CaretRight}
-      hideText
-      isDisabled
-    >
-      Next Epoch
-    </Button>
-  </div>
-);

+ 142 - 22
apps/insights/src/components/Publishers/index.module.scss

@@ -1,40 +1,112 @@
 @use "@pythnetwork/component-library/theme";
+@use "../Root/index.module.scss" as root;
 
-.publisherNameContainer {
-  display: flex;
-  flex-flow: row nowrap;
-  align-items: center;
-  gap: theme.spacing(4);
+.publishers {
+  @include theme.max-width;
 
-  .publisherIcon {
-    display: flex;
-    flex: none;
-    width: theme.spacing(9);
-    height: theme.spacing(9);
+  .header {
+    @include theme.h3;
+
+    color: theme.color("heading");
+    font-weight: theme.font-weight("semibold");
+    margin: theme.spacing(6) 0;
   }
 
-  .nameAndKey {
+  .body {
     display: flex;
-    flex-direction: column;
-    gap: theme.spacing(1);
+    flex-flow: row nowrap;
+    gap: theme.spacing(12);
+    align-items: flex-start;
 
-    .publisherName {
-      font-size: theme.font-size("sm");
-      font-weight: theme.font-weight("medium");
-      line-height: theme.spacing(4);
+    .stats {
+      display: grid;
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+      gap: theme.spacing(4);
+      align-items: center;
+      width: 40%;
+      position: sticky;
+      top: root.$header-height;
+
+      .oisCard {
+        grid-column: span 2 / span 2;
+
+        .oisPool {
+          width: 100%;
+          height: theme.spacing(72);
+          overflow: hidden;
+          display: grid;
+          place-content: center;
+          position: relative;
+
+          .oisPoolChart {
+            position: relative;
+            top: theme.spacing(8);
+
+            .bar {
+              fill: theme.color("button", "primary", "background", "normal");
+            }
+
+            .background {
+              fill: theme.color("button", "disabled", "background");
+            }
+          }
+
+          .legend {
+            text-align: center;
+            position: absolute;
+            top: theme.spacing(30);
+            display: flex;
+            width: 100%;
+            flex-flow: column nowrap;
+            align-items: center;
+            gap: theme.spacing(1.5);
+
+            .title {
+              font-size: theme.font-size("sm");
+              font-weight: theme.font-weight("normal");
+              color: theme.color("heading");
+              margin: 0;
+            }
+
+            .poolUsed {
+              margin: 0;
+              color: theme.color("heading");
+
+              @include theme.h3;
+            }
+
+            .poolTotal {
+              margin: 0;
+              color: theme.color("muted");
+              font-size: theme.font-size("sm");
+              font-weight: theme.font-weight("normal");
+            }
+          }
+        }
+
+        .oisStats {
+          display: grid;
+          grid-template-columns: repeat(2, minmax(0, 1fr));
+          gap: theme.spacing(1);
+        }
+      }
+    }
+
+    .publishersCard {
+      width: 60%;
     }
   }
 }
 
-.publisherKey {
-  font-size: theme.font-size("xs");
+.ranking,
+.rankingLoader {
+  height: theme.spacing(6);
+  border-radius: theme.border-radius("md");
+  width: 100%;
 }
 
 .ranking {
   display: inline-block;
-  height: theme.spacing(6);
-  width: 100%;
-  border-radius: theme.border-radius("md");
   text-align: center;
   font-size: theme.font-size("sm");
   font-weight: theme.font-weight("medium");
@@ -48,3 +120,51 @@
     theme.pallette-color("steel", 700)
   );
 }
+
+.publisherName {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(4);
+  align-items: center;
+
+  .publisherIcon {
+    width: theme.spacing(9);
+    height: theme.spacing(9);
+  }
+
+  &.publisherNamePlaceholder .publisherIcon {
+    border-radius: theme.border-radius("full");
+  }
+
+  .nameAndKey {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(1);
+    align-items: flex-start;
+
+    .publisherKey {
+      color: theme.color("link", "normal");
+      font-weight: theme.font-weight("medium");
+      font-size: theme.font-size("xxs");
+    }
+  }
+
+  .name {
+    color: theme.color("heading");
+  }
+
+  &[data-is-undisclosed] {
+    .undisclosedIconWrapper {
+      background: theme.color("button", "disabled", "background");
+      border-radius: theme.border-radius("full");
+      display: grid;
+      place-content: center;
+
+      .undisclosedIcon {
+        width: theme.spacing(4);
+        height: theme.spacing(4);
+        color: theme.color("button", "disabled", "foreground");
+      }
+    }
+  }
+}

+ 206 - 44
apps/insights/src/components/Publishers/index.tsx

@@ -1,83 +1,245 @@
+import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
+import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { ButtonLink } from "@pythnetwork/component-library/Button";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import clsx from "clsx";
-import { type ComponentProps, createElement } from "react";
+import type { ComponentProps } from "react";
 import { z } from "zod";
 
 import styles from "./index.module.scss";
-import { Results } from "./results";
-import { client as clickhouseClient } from "../../clickhouse";
-import { client as pythClient } from "../../pyth";
+import { PublishersCard } from "./publishers-card";
+import { SemicircleMeter, Label } from "./semicircle-meter";
+import { client as clickhouseClient } from "../../services/clickhouse";
+import { client as hermesClient } from "../../services/hermes";
+import { CLUSTER, client as pythClient } from "../../services/pyth";
+import { client as stakingClient } from "../../services/staking";
 import { CopyButton } from "../CopyButton";
+import { FormattedTokens } from "../FormattedTokens";
+import { TokenIcon } from "../TokenIcon";
+
+const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n;
 
 export const Publishers = async () => {
-  const [publishers, feedCount] = await Promise.all([
+  const [publishers, totalFeeds, oisStats] = await Promise.all([
     getPublishers(),
-    getFeedCount(),
+    getTotalFeedCount(),
+    getOisStats(),
   ]);
 
   return (
-    <Results
-      publishers={publishers.map(({ key, rank, numSymbols }) => ({
-        key,
-        rank,
-        data: {
-          name: <PublisherName>{key}</PublisherName>,
-          rank: <Ranking>{rank}</Ranking>,
-          activeFeeds: numSymbols,
-          inactiveFeeds: feedCount - numSymbols,
-          score: 0,
-        },
-      }))}
-    />
+    <div className={styles.publishers}>
+      <h1 className={styles.header}>Publishers</h1>
+      <div className={styles.body}>
+        <div className={styles.stats}>
+          <StatCard
+            variant="primary"
+            header="Active Publishers"
+            stat={publishers.length}
+          />
+          <StatCard
+            header="Avg. Median Score"
+            stat={(
+              publishers.reduce(
+                (sum, publisher) => sum + publisher.medianScore,
+                0,
+              ) / publishers.length
+            ).toFixed(2)}
+          />
+          <Card
+            title="Oracle Integrity Staking (OIS)"
+            className={styles.oisCard}
+            toolbar={
+              <ButtonLink
+                href="https://staking.pyth.network"
+                target="_blank"
+                size="sm"
+                variant="outline"
+                afterIcon={ArrowSquareOut}
+              >
+                Staking App
+              </ButtonLink>
+            }
+          >
+            <SemicircleMeter
+              width={340}
+              height={340}
+              value={Number(oisStats.totalStaked)}
+              maxValue={oisStats.maxPoolSize ?? 0}
+              className={styles.oisPool ?? ""}
+              chartClassName={styles.oisPoolChart}
+              barClassName={styles.bar}
+              backgroundClassName={styles.background}
+            >
+              <div className={styles.legend}>
+                <Label className={styles.title}>PYTH Staking Pool</Label>
+                <p className={styles.poolUsed}>
+                  <FormattedTokens mode="wholePart">
+                    {oisStats.totalStaked}
+                  </FormattedTokens>
+                </p>
+                <p className={styles.poolTotal}>
+                  /{" "}
+                  <FormattedTokens mode="wholePart">
+                    {BigInt(oisStats.maxPoolSize ?? 0)}
+                  </FormattedTokens>
+                </p>
+              </div>
+            </SemicircleMeter>
+            <div className={styles.oisStats}>
+              <StatCard
+                header="Total Staked"
+                variant="tertiary"
+                stat={
+                  <>
+                    <TokenIcon />
+                    <FormattedTokens>{oisStats.totalStaked}</FormattedTokens>
+                  </>
+                }
+              />
+              <StatCard
+                header="Total Rewards Distributed"
+                variant="tertiary"
+                stat={
+                  <>
+                    <TokenIcon />
+                    <FormattedTokens>
+                      {oisStats.rewardsDistributed}
+                    </FormattedTokens>
+                  </>
+                }
+              />
+            </div>
+          </Card>
+        </div>
+        <PublishersCard
+          className={styles.publishersCard}
+          rankingLoadingSkeleton={
+            <Skeleton className={styles.rankingLoader} fill />
+          }
+          nameLoadingSkeleton={
+            <div
+              className={clsx(
+                styles.publisherName,
+                styles.publisherNamePlaceholder,
+              )}
+            >
+              <Skeleton className={styles.publisherIcon} fill />
+              <div className={styles.nameAndKey}>
+                <div className={styles.name}>
+                  <Skeleton width={40} />
+                </div>
+                <Skeleton className={styles.publisherKey ?? ""} width={20} />
+              </div>
+            </div>
+          }
+          publishers={publishers.map(
+            ({ key, rank, numSymbols, medianScore }) => ({
+              id: key,
+              nameAsString: lookupPublisher(key)?.name,
+              name: <PublisherName>{key}</PublisherName>,
+              ranking: <Ranking>{rank}</Ranking>,
+              activeFeeds: numSymbols,
+              inactiveFeeds: totalFeeds - numSymbols,
+              medianScore,
+            }),
+          )}
+        />
+      </div>
+    </div>
   );
 };
 
+const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
+  <span className={clsx(styles.ranking, className)} {...props} />
+);
+
 const PublisherName = ({ children }: { children: string }) => {
   const knownPublisher = lookupPublisher(children);
-  return knownPublisher ? (
-    <div className={styles.publisherNameContainer}>
-      {createElement(knownPublisher.icon.color, {
-        className: styles.publisherIcon,
-      })}
-      <div className={styles.nameAndKey}>
-        <div className={styles.publisherName}>{knownPublisher.name}</div>
-        <CopyButton className={styles.publisherKey ?? ""} text={children}>
-          {children}
+  const Icon = knownPublisher?.icon.color ?? UndisclosedIcon;
+  const name = knownPublisher?.name ?? "Undisclosed";
+  return (
+    <div
+      data-is-undisclosed={knownPublisher === undefined ? "" : undefined}
+      className={styles.publisherName}
+    >
+      <Icon className={styles.publisherIcon} />
+      {knownPublisher ? (
+        <div className={styles.nameAndKey}>
+          <div className={styles.name}>{name}</div>
+          <CopyButton className={styles.publisherKey ?? ""} text={children}>
+            {`${children.slice(0, 4)}...${children.slice(-4)}`}
+          </CopyButton>
+        </div>
+      ) : (
+        <CopyButton className={styles.name ?? ""} text={children}>
+          {`${children.slice(0, 4)}...${children.slice(-4)}`}
         </CopyButton>
-      </div>
+      )}
     </div>
-  ) : (
-    <CopyButton className={styles.publisherKey ?? ""} text={children}>
-      {children}
-    </CopyButton>
   );
 };
 
-const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
-  <span className={clsx(styles.ranking, className)} {...props} />
+const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
+  <div className={clsx(styles.undisclosedIconWrapper, className)} {...props}>
+    <Broadcast className={styles.undisclosedIcon} />
+  </div>
 );
 
 const getPublishers = async () => {
   const rows = await clickhouseClient.query({
-    query: "SELECT key, rank, numSymbols FROM insights_publishers",
+    query:
+      "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})",
+    query_params: { cluster: CLUSTER },
   });
   const result = await rows.json();
 
   return publishersSchema.parse(result.data);
 };
 
-const getFeedCount = async () => {
-  const pythData = await pythClient.getData();
-  return pythData.symbols.filter(
-    (symbol) =>
-      (pythData.productPrice.get(symbol)?.numComponentPrices ?? 0) > 0,
-  ).length;
-};
-
 const publishersSchema = z.array(
   z.strictObject({
     key: z.string(),
     rank: z.number(),
     numSymbols: z.number(),
+    medianScore: z.number(),
   }),
 );
+
+const getTotalFeedCount = async () => {
+  const pythData = await pythClient.getData();
+  return pythData.symbols.filter(
+    (symbol) =>
+      (pythData.productPrice.get(symbol)?.numComponentPrices ?? 0) > 0,
+  ).length;
+};
+
+const getOisStats = async () => {
+  const [poolData, rewardCustodyAccount, publisherCaps] = await Promise.all([
+    stakingClient.getPoolDataAccount(),
+    stakingClient.getRewardCustodyAccount(),
+    hermesClient.getLatestPublisherCaps({ parsed: true }),
+  ]);
+
+  return {
+    totalStaked:
+      sumDelegations(poolData.delState) + sumDelegations(poolData.selfDelState),
+    rewardsDistributed:
+      poolData.claimableRewards +
+      INITIAL_REWARD_POOL_SIZE -
+      rewardCustodyAccount.amount,
+    maxPoolSize: publisherCaps.parsed?.[0]?.publisher_stake_caps
+      .map(({ cap }) => cap)
+      .reduce((acc, value) => acc + value),
+  };
+};
+
+const sumDelegations = (
+  values: { totalDelegation: bigint; deltaDelegation: bigint }[],
+) =>
+  values.reduce(
+    (acc, value) => acc + value.totalDelegation + value.deltaDelegation,
+    0n,
+  );

+ 0 - 13
apps/insights/src/components/Publishers/layout.module.scss

@@ -1,13 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.publishersLayout {
-  @include theme.max-width;
-
-  .header {
-    margin-bottom: theme.spacing(12);
-    display: flex;
-    flex-flow: row nowrap;
-    align-items: center;
-    justify-content: space-between;
-  }
-}

+ 0 - 19
apps/insights/src/components/Publishers/layout.tsx

@@ -1,19 +0,0 @@
-import type { ReactNode } from "react";
-
-import { EpochSelect } from "./epoch-select";
-import styles from "./layout.module.scss";
-import { H1 } from "../H1";
-
-type Props = {
-  children: ReactNode | undefined;
-};
-
-export const PublishersLayout = ({ children }: Props) => (
-  <div className={styles.publishersLayout}>
-    <div className={styles.header}>
-      <H1>Publishers</H1>
-      <EpochSelect />
-    </div>
-    {children}
-  </div>
-);

+ 0 - 10
apps/insights/src/components/Publishers/loading.tsx

@@ -1,10 +0,0 @@
-import { Card } from "@pythnetwork/component-library/Card";
-import { Table } from "@pythnetwork/component-library/Table";
-
-import { columns } from "./columns";
-
-export const PublishersLoading = () => (
-  <Card title="Publishers">
-    <Table label="Publishers" columns={columns} isLoading />
-  </Card>
-);

+ 66 - 0
apps/insights/src/components/Publishers/publishers-card.module.scss

@@ -0,0 +1,66 @@
+@use "@pythnetwork/component-library/theme";
+
+.publishersCard {
+  .publisherScore {
+    width: calc(theme.spacing(1) * var(--width));
+    height: theme.spacing(4);
+    border-radius: theme.border-radius("3xl");
+    position: relative;
+
+    .fill {
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      border-radius: theme.border-radius("3xl");
+      color: theme.color("background", "primary");
+      display: grid;
+      place-content: center;
+      text-shadow:
+        0 1px 2px rgb(0 0 0 / 10%),
+        0 1px 3px rgb(0 0 0 / 10%);
+      font-size: theme.font-size("xxs");
+      font-weight: theme.font-weight("semibold");
+    }
+
+    &[data-size-class="bad"] {
+      background: theme.color("states", "error", "background");
+
+      .fill {
+        background: theme.color("states", "error", "color");
+      }
+    }
+
+    &[data-size-class="weak"] {
+      background: theme.color("states", "warning", "background");
+
+      .fill {
+        background: theme.color("states", "warning", "normal");
+      }
+    }
+
+    &[data-size-class="warn"] {
+      background: theme.color("states", "yellow", "background");
+
+      .fill {
+        background: theme.color("states", "yellow", "normal");
+      }
+    }
+
+    &[data-size-class="ok"] {
+      background: theme.color("states", "lime", "background");
+
+      .fill {
+        background: theme.color("states", "lime", "normal");
+      }
+    }
+
+    &[data-size-class="good"] {
+      background: theme.color("states", "success", "background");
+
+      .fill {
+        background: theme.color("states", "success", "normal");
+      }
+    }
+  }
+}

+ 323 - 0
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -0,0 +1,323 @@
+"use client";
+
+import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { useLogger } from "@pythnetwork/app-logger";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
+import clsx from "clsx";
+import { usePathname } from "next/navigation";
+import {
+  parseAsString,
+  parseAsInteger,
+  useQueryStates,
+  createSerializer,
+} from "nuqs";
+import {
+  type ReactNode,
+  type CSSProperties,
+  Suspense,
+  useCallback,
+  useMemo,
+} from "react";
+import { useFilter } from "react-aria";
+import { Meter } from "react-aria-components";
+
+import styles from "./publishers-card.module.scss";
+
+const PUBLISHER_SCORE_WIDTH = 24;
+
+type Props = {
+  className?: string | undefined;
+  rankingLoadingSkeleton: ReactNode;
+  nameLoadingSkeleton: ReactNode;
+  publishers: Publisher[];
+};
+
+type Publisher = {
+  id: string;
+  nameAsString: string | undefined;
+  name: ReactNode;
+  ranking: ReactNode;
+  activeFeeds: ReactNode;
+  inactiveFeeds: ReactNode;
+  medianScore: number;
+};
+
+export const PublishersCard = ({ publishers, ...props }: Props) => (
+  <Suspense fallback={<PublishersCardContents isLoading {...props} />}>
+    <ResolvedPublishersCard publishers={publishers} {...props} />
+  </Suspense>
+);
+
+const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
+  const logger = useLogger();
+
+  const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams);
+
+  const updateQuery = useCallback(
+    (...params: Parameters<typeof setQuery>) => {
+      setQuery(...params).catch((error: unknown) => {
+        logger.error("Failed to update query", error);
+      });
+    },
+    [setQuery, logger],
+  );
+
+  const updateSearch = useCallback(
+    (newSearch: string) => {
+      updateQuery({ page: 1, search: newSearch });
+    },
+    [updateQuery],
+  );
+
+  const updatePage = useCallback(
+    (newPage: number) => {
+      updateQuery({ page: newPage });
+    },
+    [updateQuery],
+  );
+
+  const updatePageSize = useCallback(
+    (newPageSize: number) => {
+      updateQuery({ page: 1, pageSize: newPageSize });
+    },
+    [updateQuery],
+  );
+
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const filteredPublishers = useMemo(
+    () =>
+      search === ""
+        ? publishers
+        : publishers.filter(
+            (publisher) =>
+              filter.contains(publisher.id, search) ||
+              (publisher.nameAsString !== undefined &&
+                filter.contains(publisher.nameAsString, search)),
+          ),
+    [publishers, search, filter],
+  );
+  const paginatedPublishers = useMemo(
+    () => filteredPublishers.slice((page - 1) * pageSize, page * pageSize),
+    [page, pageSize, filteredPublishers],
+  );
+
+  const numPages = useMemo(
+    () => Math.ceil(filteredPublishers.length / pageSize),
+    [filteredPublishers.length, pageSize],
+  );
+
+  const pathname = usePathname();
+
+  const mkPageLink = useCallback(
+    (page: number) => {
+      const serialize = createSerializer(queryParams);
+      return `${pathname}${serialize({ page, pageSize })}`;
+    },
+    [pathname, pageSize],
+  );
+
+  const rows = useMemo(
+    () =>
+      paginatedPublishers.map(({ id, medianScore, ...data }) => ({
+        id,
+        href: "#",
+        data: {
+          ...data,
+          medianScore: <PublisherScore>{medianScore}</PublisherScore>,
+        },
+      })),
+    [paginatedPublishers],
+  );
+
+  return (
+    <PublishersCardContents
+      numResults={filteredPublishers.length}
+      search={search}
+      numPages={numPages}
+      page={page}
+      pageSize={pageSize}
+      onSearchChange={updateSearch}
+      onPageSizeChange={updatePageSize}
+      onPageChange={updatePage}
+      mkPageLink={mkPageLink}
+      rows={rows}
+      {...props}
+    />
+  );
+};
+
+const queryParams = {
+  page: parseAsInteger.withDefault(1),
+  pageSize: parseAsInteger.withDefault(30),
+  search: parseAsString.withDefault(""),
+};
+
+type PublishersCardContentsProps = Pick<
+  Props,
+  "className" | "rankingLoadingSkeleton" | "nameLoadingSkeleton"
+> &
+  (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        numResults: number;
+        search: string;
+        numPages: number;
+        page: number;
+        pageSize: number;
+        onSearchChange: (newSearch: string) => void;
+        onPageSizeChange: (newPageSize: number) => void;
+        onPageChange: (newPage: number) => void;
+        mkPageLink: (page: number) => string;
+        rows: RowConfig<
+          "ranking" | "name" | "activeFeeds" | "inactiveFeeds" | "medianScore"
+        >[];
+      }
+  );
+
+const PublishersCardContents = ({
+  className,
+  rankingLoadingSkeleton,
+  nameLoadingSkeleton,
+  ...props
+}: PublishersCardContentsProps) => (
+  <Card
+    className={clsx(styles.publishersCard, className)}
+    icon={<Broadcast />}
+    title={
+      <>
+        <span>Publishers</span>
+        {!props.isLoading && (
+          <Badge style="filled" variant="neutral" size="md">
+            {props.numResults}
+          </Badge>
+        )}
+      </>
+    }
+    toolbar={
+      <SearchInput
+        size="sm"
+        width={40}
+        {...(props.isLoading
+          ? { isPending: true, isDisabled: true }
+          : {
+              defaultValue: props.search,
+              onChange: props.onSearchChange,
+            })}
+      />
+    }
+    {...(!props.isLoading && {
+      footer: (
+        <Paginator
+          numPages={props.numPages}
+          currentPage={props.page}
+          onPageChange={props.onPageChange}
+          pageSize={props.pageSize}
+          onPageSizeChange={props.onPageSizeChange}
+          pageSizeOptions={[10, 20, 30, 40, 50]}
+          mkPageLink={props.mkPageLink}
+        />
+      ),
+    })}
+  >
+    <Table
+      rounded
+      fill
+      label="Publishers"
+      columns={[
+        {
+          id: "ranking",
+          name: "RANKING",
+          width: 10,
+          loadingSkeleton: rankingLoadingSkeleton,
+        },
+        {
+          id: "name",
+          name: "NAME / ID",
+          isRowHeader: true,
+          fill: true,
+          alignment: "left",
+          loadingSkeleton: nameLoadingSkeleton,
+        },
+        {
+          id: "activeFeeds",
+          name: "ACTIVE FEEDS",
+          alignment: "center",
+          width: 10,
+        },
+        {
+          id: "inactiveFeeds",
+          name: "INACTIVE FEEDS",
+          alignment: "center",
+          width: 10,
+        },
+        {
+          id: "medianScore",
+          name: "MEDIAN SCORE",
+          width: PUBLISHER_SCORE_WIDTH,
+          alignment: "center",
+          loadingSkeleton: (
+            <Skeleton
+              className={styles.publisherScore}
+              fill
+              style={{ "--width": PUBLISHER_SCORE_WIDTH } as CSSProperties}
+            />
+          ),
+        },
+      ]}
+      {...(props.isLoading
+        ? {
+            isLoading: true,
+          }
+        : {
+            rows: props.rows,
+            renderEmptyState: () => <p>No results!</p>,
+          })}
+    />
+  </Card>
+);
+
+type PublisherScoreProps = {
+  children: number;
+};
+
+const PublisherScore = ({ children }: PublisherScoreProps) => (
+  <Meter
+    value={children}
+    maxValue={1}
+    style={{ "--width": PUBLISHER_SCORE_WIDTH } as CSSProperties}
+  >
+    {({ percentage }) => (
+      <div
+        className={styles.publisherScore}
+        data-size-class={getSizeClass(percentage)}
+      >
+        <div
+          className={styles.fill}
+          style={{ width: `${(50 + percentage / 2).toString()}%` }}
+        >
+          {children.toFixed(2)}
+        </div>
+      </div>
+    )}
+  </Meter>
+);
+
+const getSizeClass = (percentage: number) => {
+  if (percentage < 60) {
+    return "bad";
+  } else if (percentage < 70) {
+    return "weak";
+  } else if (percentage < 80) {
+    return "warn";
+  } else if (percentage < 90) {
+    return "ok";
+  } else {
+    return "good";
+  }
+};

+ 0 - 103
apps/insights/src/components/Publishers/results.tsx

@@ -1,103 +0,0 @@
-"use client";
-
-import { useLogger } from "@pythnetwork/app-logger";
-import { Card } from "@pythnetwork/component-library/Card";
-import { Paginator } from "@pythnetwork/component-library/Paginator";
-import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
-import { usePathname } from "next/navigation";
-import { parseAsInteger, useQueryStates, createSerializer } from "nuqs";
-import { useTransition, useMemo, useCallback } from "react";
-
-import { columns } from "./columns";
-
-type Props = {
-  publishers: {
-    key: string;
-    rank: number;
-    data: RowConfig<(typeof columns)[number]["id"]>["data"];
-  }[];
-};
-
-const params = {
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(20),
-};
-
-export const Results = ({ publishers }: Props) => {
-  const [isTransitioning, startTransition] = useTransition();
-  const [{ page, pageSize }, setQuery] = useQueryStates(params);
-  const rows = useMemo(
-    () =>
-      publishers
-        .sort((a, b) => a.rank - b.rank)
-        .slice((page - 1) * pageSize, page * pageSize)
-        .map(({ key, data }) => ({ id: key, href: "/", data })),
-    [page, pageSize, publishers],
-  );
-  const numPages = useMemo(
-    () => Math.ceil(publishers.length / pageSize),
-    [publishers, pageSize],
-  );
-
-  const logger = useLogger();
-
-  const updateQuery = useCallback(
-    (...params: Parameters<typeof setQuery>) => {
-      window.scrollTo({ top: 0, behavior: "smooth" });
-      startTransition(() => {
-        setQuery(...params).catch((error: unknown) => {
-          logger.error("Failed to update query", error);
-        });
-      });
-    },
-    [setQuery, startTransition, logger],
-  );
-
-  const updatePage = useCallback(
-    (newPage: number) => {
-      updateQuery({ page: newPage });
-    },
-    [updateQuery],
-  );
-
-  const updatePageSize = useCallback(
-    (newPageSize: number) => {
-      updateQuery({ page: 1, pageSize: newPageSize });
-    },
-    [updateQuery],
-  );
-
-  const pathname = usePathname();
-
-  const mkPageLink = useCallback(
-    (page: number) => {
-      const serialize = createSerializer(params);
-      return `${pathname}${serialize({ page, pageSize })}`;
-    },
-    [pathname, pageSize],
-  );
-
-  return (
-    <Card
-      title="Publishers"
-      footer={
-        <Paginator
-          numPages={numPages}
-          currentPage={page}
-          onPageChange={updatePage}
-          pageSize={pageSize}
-          onPageSizeChange={updatePageSize}
-          mkPageLink={mkPageLink}
-          pageSizeOptions={[10, 20, 30, 40, 50]}
-        />
-      }
-    >
-      <Table
-        label="Publishers"
-        columns={columns}
-        isUpdating={isTransitioning}
-        rows={rows}
-      />
-    </Card>
-  );
-};

+ 95 - 0
apps/insights/src/components/Publishers/semicircle-meter.tsx

@@ -0,0 +1,95 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { type ComponentProps, Suspense } from "react";
+import { Meter } from "react-aria-components";
+import { PolarAngleAxis, RadialBar } from "recharts";
+
+export { Label } from "react-aria-components";
+
+const RadialBarChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.RadialBarChart),
+  {
+    ssr: false,
+  },
+);
+
+type OwnProps = Pick<
+  ComponentProps<typeof RadialBarChart>,
+  "width" | "height"
+> & {
+  chartClassName?: string | undefined;
+  barClassName?: string | undefined;
+  backgroundClassName?: string | undefined;
+};
+
+type Props = Omit<ComponentProps<typeof Meter>, keyof OwnProps> & OwnProps;
+
+export const SemicircleMeter = ({
+  width,
+  height,
+  chartClassName,
+  barClassName,
+  backgroundClassName,
+  children,
+  ...props
+}: Props) => (
+  <Meter {...props}>
+    {({ percentage }) => (
+      <>
+        <Suspense>
+          <Chart
+            percentage={percentage}
+            chartClassName={chartClassName}
+            backgroundClassName={backgroundClassName}
+            barClassName={barClassName}
+            {...(width && { width })}
+            {...(height && { height })}
+          />
+        </Suspense>
+        {children}
+      </>
+    )}
+  </Meter>
+);
+
+type ChartProps = Pick<
+  Props,
+  "width" | "height" | "chartClassName" | "backgroundClassName" | "barClassName"
+> & {
+  percentage: number;
+};
+
+const Chart = ({
+  width,
+  height,
+  percentage,
+  chartClassName,
+  backgroundClassName,
+  barClassName,
+}: ChartProps) => (
+  <RadialBarChart
+    data={[{ value: percentage }]}
+    innerRadius="100%"
+    startAngle={210}
+    endAngle={-30}
+    barSize={16}
+    className={chartClassName ?? ""}
+    {...(width && { width })}
+    {...(height && { height })}
+  >
+    <PolarAngleAxis
+      type="number"
+      domain={[0, 100]}
+      angleAxisId={0}
+      tick={false}
+    />
+    <RadialBar
+      angleAxisId={0}
+      background={{ className: backgroundClassName }}
+      dataKey="value"
+      className={barClassName ?? ""}
+      cornerRadius={999}
+    />
+  </RadialBarChart>
+);

+ 1 - 0
apps/insights/src/components/Root/footer.module.scss

@@ -42,6 +42,7 @@
         box-sizing: content-box;
         padding: theme.spacing(3);
         margin: -#{theme.spacing(3)};
+        color: theme.color("foreground");
 
         .logo {
           height: 100%;

+ 1 - 0
apps/insights/src/components/Root/header.module.scss

@@ -23,6 +23,7 @@
       .logoLink {
         padding: theme.spacing(3);
         margin: -#{theme.spacing(3)};
+        color: theme.color("foreground");
 
         .logoWrapper {
           width: theme.spacing(9);

+ 1 - 1
apps/insights/src/components/Root/header.tsx

@@ -14,7 +14,7 @@ export const Header = ({ className, ...props }: ComponentProps<"header">) => (
   <header className={clsx(styles.header, className)} {...props}>
     <div className={styles.content}>
       <div className={styles.leftMenu}>
-        <Link href="https://www.pyth.network" className={styles.logoLink ?? ""}>
+        <Link href="/" className={styles.logoLink ?? ""}>
           <div className={styles.logoWrapper}>
             <Logo className={styles.logo} />
           </div>

+ 2 - 1
apps/insights/src/components/Root/index.tsx

@@ -6,7 +6,8 @@ import { Footer } from "./footer";
 import { Header } from "./header";
 // import { MobileMenu } from "./mobile-menu";
 import styles from "./index.module.scss";
-import { TabPanel, TabRoot } from "./tabs";
+import { TabPanel } from "./tab-panel";
+import { TabRoot } from "./tabs";
 import {
   IS_PRODUCTION_SERVER,
   GOOGLE_ANALYTICS_ID,

+ 3 - 3
apps/insights/src/components/Root/logo.svg

@@ -1,4 +1,4 @@
-<svg viewBox="0 0 36 46" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
-  <path d="M22.5 18.013a4.502 4.502 0 0 1-4.5 4.503v4.504c4.97 0 9-4.033 9-9.007 0-4.974-4.03-9.007-9-9.007a9 9 0 0 0-9 9.007V40.53l4.046 4.049.454.454v-27.02a4.502 4.502 0 0 1 4.5-4.504c2.485 0 4.5 2.017 4.5 4.504Z"/>
-  <path d="M18 0c-3.279 0-6.352.878-9 2.412A18 18 0 0 0 4.5 6.1 17.952 17.952 0 0 0 0 18.014v13.51l4.5 4.503V18.013c0-4 1.738-7.595 4.5-10.07a13.473 13.473 0 0 1 9-3.44c7.455 0 13.5 6.05 13.5 13.51 0 7.461-6.045 13.51-13.5 13.51v4.504c9.942 0 18-8.066 18-18.014C36 8.067 27.942 0 18 0Z"/>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32.3 41" fill="currentColor">
+<path d="M19.9998 16.5133C19.9998 18.7239 18.2087 20.5163 15.9998 20.5163V24.5193C20.4177 24.5193 23.9998 20.9346 23.9998 16.5133C23.9998 12.0921 20.4177 8.50732 15.9998 8.50732C14.5434 8.50732 13.1757 8.89658 11.9998 9.57914C9.60808 10.9624 7.99976 13.5496 7.99976 16.5133V36.5283L11.5963 40.1276L11.9998 40.5313V16.5133C11.9998 14.3027 13.7908 12.5103 15.9998 12.5103C18.2087 12.5103 19.9998 14.3027 19.9998 16.5133Z"/>
+<path d="M16 0.501953C13.0855 0.501953 10.3537 1.28228 8 2.64558C6.49299 3.51643 5.14337 4.62626 4 5.92438C1.51063 8.74694 0 12.4548 0 16.514V28.523L4 32.526V16.514C4 12.9582 5.545 9.76263 8 7.56288C9.15423 6.5309 10.5093 5.71618 12 5.19113C13.2501 4.74575 14.5979 4.50496 16 4.50496C22.6269 4.50496 28 9.88212 28 16.514C28 23.1458 22.6269 28.523 16 28.523V32.526C24.8376 32.526 32 25.3564 32 16.514C32 7.67151 24.8376 0.501953 16 0.501953Z"/>
 </svg>

+ 64 - 0
apps/insights/src/components/Root/tab-panel.tsx

@@ -0,0 +1,64 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
+import { useSelectedLayoutSegment } from "next/navigation";
+import { type ReactNode, useContext, useEffect, useRef } from "react";
+
+import { TabPanel as TabPanelComponent } from "./tabs";
+
+type Props = {
+  children: ReactNode;
+};
+
+export const TabPanel = ({ children }: Props) => {
+  const segment = useSelectedLayoutSegment();
+
+  return (
+    <AnimatePresence mode="wait" initial={false}>
+      <MotionTabPanel
+        key={segment}
+        initial={{ opacity: 0 }}
+        animate={{ opacity: 1 }}
+        exit={{ opacity: 0 }}
+      >
+        <FrozenRouter>{children}</FrozenRouter>
+      </MotionTabPanel>
+    </AnimatePresence>
+  );
+};
+
+// @ts-expect-error Looks like there's a typing mismatch currently between
+// motion and react, probably due to us being on react 19.  I'm expecting this
+// will go away when react 19 is officially stabilized...
+const MotionTabPanel = motion.create(TabPanelComponent);
+
+const FrozenRouter = ({ children }: { children: ReactNode }) => {
+  const context = useContext(LayoutRouterContext);
+  // eslint-disable-next-line unicorn/no-null
+  const prevContext = usePreviousValue(context) ?? null;
+
+  const segment = useSelectedLayoutSegment();
+  const prevSegment = usePreviousValue(segment);
+
+  const changed = segment !== prevSegment && prevSegment !== undefined;
+
+  return (
+    <LayoutRouterContext.Provider value={changed ? prevContext : context}>
+      {children}
+    </LayoutRouterContext.Provider>
+  );
+};
+
+const usePreviousValue = <T,>(value: T): T | undefined => {
+  const prevValue = useRef<T>(undefined);
+
+  useEffect(() => {
+    prevValue.current = value;
+    return () => {
+      prevValue.current = undefined;
+    };
+  });
+
+  return prevValue.current;
+};

+ 23 - 0
apps/insights/src/components/TokenIcon/index.module.scss

@@ -0,0 +1,23 @@
+@use "@pythnetwork/component-library/theme";
+
+.tokenIcon {
+  display: inline-block;
+  background: theme.pallette-color("purple", 100);
+  color: theme.pallette-color("steel", 950);
+  padding: 0.35em;
+  border-radius: theme.border-radius("full");
+  margin-right: 0.25em;
+  vertical-align: bottom;
+
+  .logoContainer {
+    width: 0.65em;
+    height: 0.65em;
+    position: relative;
+
+    .logo {
+      position: absolute;
+      width: 100%;
+      top: -0.05em;
+    }
+  }
+}

+ 17 - 0
apps/insights/src/components/TokenIcon/index.tsx

@@ -0,0 +1,17 @@
+"use client";
+
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+import Logo from "../Root/logo.svg";
+
+type Props = Omit<ComponentProps<"span">, "children">;
+
+export const TokenIcon = ({ className, ...props }: Props) => (
+  <span className={clsx(styles.tokenIcon, className)} {...props}>
+    <div className={styles.logoContainer}>
+      <Logo className={styles.logo} />
+    </div>
+  </span>
+);

+ 2 - 0
apps/insights/src/icons.tsx

@@ -1,3 +1,5 @@
+import "server-only";
+
 import Pac from "cryptocurrency-icons/svg/color/$pac.svg";
 import ZeroXbtc from "cryptocurrency-icons/svg/color/0xbtc.svg";
 import Oneinch from "cryptocurrency-icons/svg/color/1inch.svg";

+ 3 - 1
apps/insights/src/clickhouse.ts → apps/insights/src/services/clickhouse.ts

@@ -1,5 +1,7 @@
+import "server-only";
+
 import { createClient } from "@clickhouse/client";
 
-import { CLICKHOUSE } from "./config/server";
+import { CLICKHOUSE } from "../config/server";
 
 export const client = createClient(CLICKHOUSE);

+ 5 - 0
apps/insights/src/services/hermes.ts

@@ -0,0 +1,5 @@
+import "server-only";
+
+import { HermesClient } from "@pythnetwork/hermes-client";
+
+export const client = new HermesClient("https://hermes.pyth.network");

+ 4 - 4
apps/insights/src/pyth.ts → apps/insights/src/services/pyth.ts

@@ -7,11 +7,11 @@ import {
 import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection";
 import { Connection, PublicKey } from "@solana/web3.js";
 
-const CLUSTER = "pythnet";
-
-export const connection = new Connection(getPythClusterApiUrl(CLUSTER));
-export const programKey = getPythProgramKeyForCluster(CLUSTER);
+export const CLUSTER = "pythnet";
+const connection = new Connection(getPythClusterApiUrl(CLUSTER));
+const programKey = getPythProgramKeyForCluster(CLUSTER);
 export const client = new PythHttpClient(connection, programKey);
+
 export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => {
   const pythConn = new PythConnection(
     connection,

+ 7 - 0
apps/insights/src/services/staking.ts

@@ -0,0 +1,7 @@
+import "server-only";
+
+import { PythStakingClient } from "@pythnetwork/staking-sdk";
+import { Connection } from "@solana/web3.js";
+
+const connection = new Connection("https://api.mainnet-beta.solana.com");
+export const client = new PythStakingClient({ connection });

+ 8 - 2
apps/insights/src/static-data/price-feeds.tsx

@@ -1,5 +1,5 @@
 export const priceFeeds = {
-  activeChains: "80+",
+  activeChains: "88",
   updateFrequency: "400ms",
   featuredRecentlyAdded: [
     "Crypto.PYTH/USD",
@@ -8,5 +8,11 @@ export const priceFeeds = {
     "Commodities.WTI1M",
     "Crypto.1INCH/USD",
   ],
-  featuredComingSoon: ["Rates.US2Y", "Crypto.SOL/ETH", "Commodities.BRENT2M"],
+  featuredComingSoon: [
+    "Rates.US2Y",
+    "Crypto.SOL/ETH",
+    "Crypto.ION/USD",
+    "Equity.NL.BCOIN/USD",
+    "Crypto.BSOL/SOL.RR",
+  ],
 };

+ 1 - 0
packages/component-library/src/AppTabs/index.module.scss

@@ -31,6 +31,7 @@
 
     &[data-selected] {
       color: theme.color("button", "solid", "foreground");
+      cursor: default;
     }
   }
 }

+ 8 - 6
packages/component-library/src/Card/index.module.scss

@@ -1,12 +1,12 @@
 @use "../theme";
 
 .card {
-  display: block;
+  display: flex;
   flex-direction: column;
-  gap: theme.spacing(2);
+  gap: theme.spacing(1);
   border-radius: theme.border-radius("2xl");
   text-decoration: none;
-  color: theme.color("foreground");
+  color: unset;
   outline-offset: 0;
   outline: theme.spacing(1) solid transparent;
   transition-property: outline-color, border-color, box-shadow, background;
@@ -57,9 +57,11 @@
       position: absolute;
       right: theme.spacing(3);
       top: 0;
-      bottom: 0;
-      display: grid;
-      place-content: center;
+      bottom: theme.spacing(0);
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(2);
+      align-items: center;
     }
   }
 

+ 5 - 0
packages/component-library/src/Drawer/index.module.scss

@@ -32,9 +32,14 @@
         flex-flow: row nowrap;
         justify-content: space-between;
         align-items: center;
+        color: theme.color("heading");
 
         .title {
           @include theme.h4;
+
+          display: flex;
+          flex-flow: row nowrap;
+          gap: theme.spacing(3);
         }
       }
     }

+ 12 - 1
packages/component-library/src/Drawer/index.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import clsx from "clsx";
 import { motion, AnimatePresence } from "motion/react";
@@ -7,6 +9,7 @@ import {
   type ContextType,
   use,
   useCallback,
+  useEffect,
 } from "react";
 import {
   Dialog,
@@ -18,6 +21,7 @@ import {
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
+import { useSetOverlayVisible } from "../overlay-visible-context.js";
 
 export { DialogTrigger as DrawerTrigger } from "react-aria-components";
 
@@ -46,6 +50,13 @@ type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
 
 export const Drawer = ({ title, children, className, ...props }: Props) => {
   const state = use(OverlayTriggerStateContext);
+  const { hideOverlay, showOverlay } = useSetOverlayVisible();
+
+  useEffect(() => {
+    if (state?.isOpen) {
+      showOverlay();
+    }
+  }, [state, showOverlay]);
 
   const onOpenChange = useCallback(
     (newValue: boolean) => {
@@ -55,7 +66,7 @@ export const Drawer = ({ title, children, className, ...props }: Props) => {
   );
 
   return (
-    <AnimatePresence>
+    <AnimatePresence onExitComplete={hideOverlay}>
       {state?.isOpen && (
         <ModalOverlay
           isOpen

+ 23 - 0
packages/component-library/src/Html/base.scss

@@ -7,6 +7,29 @@
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   scroll-behavior: smooth;
+  line-height: 1;
+}
+
+html {
+  // We use `scrollbar-gutter: stable` which prevents the page from jumping when
+  // adding or removing the scrollbar.  However, react-aria [tries to add a
+  // padding](https://github.com/adobe/react-spectrum/issues/5470) to the html
+  // element when opening/closing popovers and does not account for
+  // `scrollbar-gutter`, and there's no way (yet) to disable that behavior.
+  // Forcing the padding to zero here effectively prevents that behavior from
+  // causing the page to jump.
+  // TODO Remove this when a fix for
+  // https://github.com/adobe/react-spectrum/issues/5470 lands in react-aria
+  scrollbar-gutter: stable;
+  padding-right: 0 !important;
+
+  // We also have to disable `scrollbar-gutter: stable` when overlays are
+  // visible, because chrome leaves an unsightly gap rather than letting the
+  // modal backgrop fill the page even though it's fixed position.
+  &[data-overlay-visible] {
+    scrollbar-gutter: auto;
+    padding-right: var(--scrollbar-width) !important;
+  }
 }
 
 *::selection {

+ 52 - 8
packages/component-library/src/Html/index.tsx

@@ -1,14 +1,58 @@
+"use client";
+
 import { sans } from "@pythnetwork/fonts";
 import clsx from "clsx";
-import type { ComponentProps } from "react";
+import {
+  type ComponentProps,
+  type CSSProperties,
+  useState,
+  useEffect,
+} from "react";
+
+import {
+  OverlayVisibleContextProvider,
+  useIsOverlayVisible,
+} from "../overlay-visible-context.js";
 
 import "./base.scss";
 
-export const Html = ({ className, lang, ...props }: ComponentProps<"html">) => (
-  <html
-    lang={lang}
-    className={clsx(sans.className, className)}
-    data-gutter-stable
-    {...props}
-  />
+export const Html = (props: ComponentProps<"html">) => (
+  <OverlayVisibleContextProvider>
+    <HtmlInner {...props} />
+  </OverlayVisibleContextProvider>
 );
+
+const HtmlInner = ({ className, lang, ...props }: ComponentProps<"html">) => {
+  const isOverlayVisible = useIsOverlayVisible();
+  const scrollbarWidth = useScrollbarWidth();
+
+  return (
+    <html
+      lang={lang}
+      className={clsx(sans.className, className)}
+      style={
+        {
+          "--scrollbar-width": `${scrollbarWidth.toString()}px`,
+        } as CSSProperties
+      }
+      data-overlay-visible={isOverlayVisible ? "" : undefined}
+      {...props}
+    />
+  );
+};
+
+const DEFAULT_SCROLLBAR_WIDTH = 0;
+
+const useScrollbarWidth = () => {
+  const [scrollbarWidth, setScrollbarWidth] = useState(DEFAULT_SCROLLBAR_WIDTH);
+
+  useEffect(() => {
+    const scrollDiv = document.createElement("div");
+    scrollDiv.style.overflow = "scroll";
+    document.body.append(scrollDiv);
+    setScrollbarWidth(scrollDiv.offsetWidth - scrollDiv.clientWidth);
+    scrollDiv.remove();
+  }, []);
+
+  return scrollbarWidth;
+};

+ 1 - 5
packages/component-library/src/Link/index.module.scss

@@ -8,11 +8,11 @@
     outline-color 100ms linear,
     border-color 100ms linear;
   outline: theme.spacing(1) solid transparent;
-  color: inherit;
   padding: theme.spacing(1);
   margin: -#{theme.spacing(1)};
   text-underline-offset: 0.125em;
   outline-offset: 0;
+  color: theme.color("link", "normal");
 
   &[data-focus-visible] {
     outline-color: theme.color("focus-dim");
@@ -23,10 +23,6 @@
     text-decoration: none;
   }
 
-  &:active {
-    color: inherit;
-  }
-
   &[data-disabled] {
     cursor: not-allowed;
     color: theme.color("button", "disabled", "foreground");

+ 14 - 6
packages/component-library/src/Skeleton/index.module.scss

@@ -1,18 +1,26 @@
 @use "../theme";
 
+.fullSkeleton,
 .skeleton {
-  border-radius: theme.border-radius("lg");
   background: theme.color("button", "disabled", "background");
 
   @include theme.pulse;
 
+  .skeletonLabel {
+    @include theme.sr-only;
+  }
+}
+
+.fullSkeleton {
+  display: inline-block;
+}
+
+.skeleton {
+  border-radius: theme.border-radius("lg");
+
   .skeletonInner {
     display: inline flow-root;
     width: calc(theme.spacing(1) * var(--skeleton-width));
-
-    .skeletonLabel {
-      @include theme.sr-only;
-    }
   }
 
   &[data-round] {
@@ -27,7 +35,7 @@
     }
   }
 
-  &[data-fill] {
+  &[data-fill-width] {
     .skeletonInner {
       width: 100%;
     }

+ 2 - 2
packages/component-library/src/Skeleton/index.stories.tsx

@@ -17,7 +17,7 @@ const meta = {
         category: "Skeleton",
       },
     },
-    round: {
+    fill: {
       control: "boolean",
       table: {
         category: "Skeleton",
@@ -31,6 +31,6 @@ export const Skeleton = {
   args: {
     label: "Loading",
     width: 20,
-    round: false,
+    fill: false,
   },
 } satisfies StoryObj<typeof SkeletonComponent>;

+ 19 - 17
packages/component-library/src/Skeleton/index.tsx

@@ -6,24 +6,26 @@ import styles from "./index.module.scss";
 type Props = Omit<ComponentProps<"span">, "children"> & {
   width?: number | undefined;
   label?: string | undefined;
-  round?: boolean | undefined;
+  fill?: boolean | undefined;
 };
 
-export const Skeleton = ({
-  className,
-  label,
-  width,
-  round,
-  ...props
-}: Props) => (
-  <span
-    data-fill={width === undefined ? "" : undefined}
-    data-round={round ? "" : undefined}
-    {...(width && { style: { "--skeleton-width": width } as CSSProperties })}
-    className={styles.skeleton}
-  >
-    <span className={clsx(styles.skeletonInner, className)} {...props}>
-      <span className={styles.skeletonLabel}>{label ?? "Loading"}</span>
+export const Skeleton = ({ className, label, width, fill, ...props }: Props) =>
+  fill ? (
+    <span className={clsx(styles.fullSkeleton, className)} {...props}>
+      <Label>{label ?? "Loading"}</Label>
     </span>
-  </span>
+  ) : (
+    <span
+      data-fill-width={width === undefined ? "" : undefined}
+      {...(width && { style: { "--skeleton-width": width } as CSSProperties })}
+      className={clsx(styles.skeleton, { [className ?? ""]: fill })}
+    >
+      <span className={clsx(styles.skeletonInner, className)} {...props}>
+        <Label>{label ?? "Loading"}</Label>
+      </span>
+    </span>
+  );
+
+const Label = ({ children }: { children: string | undefined }) => (
+  <span className={styles.skeletonLabel}>{children ?? "Loading"}</span>
 );

+ 2 - 0
packages/component-library/src/StatCard/index.module.scss

@@ -26,6 +26,8 @@
 
       .stat {
         @include theme.h3;
+
+        color: theme.color("heading");
       }
 
       .miniStat {

+ 3 - 0
packages/component-library/src/Table/index.module.scss

@@ -91,6 +91,9 @@
     .tableBody {
       @include theme.text("sm", "medium");
 
+      color: theme.color("paragraph");
+      font-weight: theme.font-weight("medium");
+
       .row {
         background-color: transparent;
         transition-property: background-color;

+ 1 - 1
packages/component-library/src/Table/index.tsx

@@ -113,7 +113,7 @@ export const Table = <T extends string>({
                     width={
                       "loadingSkeletonWidth" in column
                         ? column.loadingSkeletonWidth
-                        : undefined
+                        : width
                     }
                   />
                 )}

+ 48 - 0
packages/component-library/src/overlay-visible-context.tsx

@@ -0,0 +1,48 @@
+import {
+  type ComponentProps,
+  type Dispatch,
+  type SetStateAction,
+  createContext,
+  useState,
+  useCallback,
+  use,
+} from "react";
+
+export const OverlayVisibleContext = createContext<
+  [boolean, Dispatch<SetStateAction<boolean>>] | undefined
+>(undefined);
+
+export const OverlayVisibleContextProvider = (
+  props: Omit<ComponentProps<typeof OverlayVisibleContext>, "value">,
+) => {
+  const overlayVisibleState = useState(false);
+  return <OverlayVisibleContext value={overlayVisibleState} {...props} />;
+};
+
+const useOverlayVisible = () => {
+  const overlayVisible = use(OverlayVisibleContext);
+  if (overlayVisible === undefined) {
+    throw new NotInitiializedError();
+  }
+  return overlayVisible;
+};
+
+export const useIsOverlayVisible = () => useOverlayVisible()[0];
+export const useSetOverlayVisible = () => {
+  const setOverlayVisible = useOverlayVisible()[1];
+  return {
+    showOverlay: useCallback(() => {
+      setOverlayVisible(true);
+    }, [setOverlayVisible]),
+    hideOverlay: useCallback(() => {
+      setOverlayVisible(false);
+    }, [setOverlayVisible]),
+  };
+};
+
+class NotInitiializedError extends Error {
+  constructor() {
+    super("This component must be a child of <OverlayVisibleContextProvider>");
+    this.name = "NotInitiializedError";
+  }
+}

+ 26 - 1
packages/component-library/src/theme.scss

@@ -424,6 +424,10 @@ $color: (
     light-dark(pallette-color("steel", 800), pallette-color("steel", 200)),
   "paragraph":
     light-dark(pallette-color("steel", 700), pallette-color("steel", 300)),
+  "link": (
+    "normal":
+      light-dark(pallette-color("steel", 800), pallette-color("steel", 50)),
+  ),
   "muted":
     light-dark(pallette-color("stone", 700), pallette-color("steel", 300)),
   "border":
@@ -441,12 +445,21 @@ $color: (
           pallette-color("emerald", 600),
           pallette-color("emerald", 500)
         ),
+      "background":
+        light-dark(
+          pallette-color("emerald", 100),
+          pallette-color("emerald", 950)
+        ),
       "normal": pallette-color("emerald", 500),
       "hover": pallette-color("emerald", 600),
       "active": pallette-color("emerald", 700),
     ),
     "error": (
       "base": light-dark(pallette-color("red", 600), pallette-color("red", 400)),
+      "color":
+        light-dark(pallette-color("red", 500), pallette-color("red", 400)),
+      "background":
+        light-dark(pallette-color("red", 100), pallette-color("red", 950)),
       "normal": pallette-color("red", 500),
       "hover": pallette-color("red", 600),
       "active": pallette-color("red", 700),
@@ -462,6 +475,18 @@ $color: (
     "warning": (
       "normal":
         light-dark(pallette-color("orange", 600), pallette-color("orange", 400)),
+      "background":
+        light-dark(pallette-color("orange", 100), pallette-color("orange", 950)),
+    ),
+    "yellow": (
+      "normal": pallette-color("yellow", 500),
+      "background":
+        light-dark(pallette-color("yellow", 100), pallette-color("yellow", 900)),
+    ),
+    "lime": (
+      "normal": pallette-color("lime", 500),
+      "background":
+        light-dark(pallette-color("lime", 100), pallette-color("lime", 900)),
     ),
     "data": (
       "normal":
@@ -715,5 +740,5 @@ $elevations: (
   font-weight: font-weight($weight);
   margin: 0;
   font-style: normal;
-  line-height: normal;
+  line-height: 1;
 }

+ 45 - 0
pnpm-lock.yaml

@@ -96,6 +96,9 @@ catalogs:
     css-loader:
       specifier: 7.1.2
       version: 7.1.2
+    dnum:
+      specifier: 2.14.0
+      version: 2.14.0
     eslint:
       specifier: 9.13.0
       version: 9.13.0
@@ -144,6 +147,9 @@ catalogs:
     react-dom:
       specifier: 19.0.0-rc-603e6108-20241029
       version: 19.0.0-rc-603e6108-20241029
+    recharts:
+      specifier: 2.14.1
+      version: 2.14.1
     sass:
       specifier: 1.80.7
       version: 1.80.7
@@ -408,6 +414,9 @@ importers:
       '@pythnetwork/fonts':
         specifier: workspace:*
         version: link:../../packages/fonts
+      '@pythnetwork/hermes-client':
+        specifier: workspace:*
+        version: link:../hermes/client/js
       '@pythnetwork/known-publishers':
         specifier: workspace:*
         version: link:../../packages/known-publishers
@@ -429,6 +438,9 @@ importers:
       cryptocurrency-icons:
         specifier: 'catalog:'
         version: 0.18.1
+      dnum:
+        specifier: 'catalog:'
+        version: 2.14.0
       framer-motion:
         specifier: 'catalog:'
         version: 11.11.10(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)
@@ -453,6 +465,9 @@ importers:
       react-dom:
         specifier: 'catalog:'
         version: 19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029)
+      recharts:
+        specifier: 'catalog:'
+        version: 2.14.1(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)
       swr:
         specifier: 'catalog:'
         version: 2.2.5(react@19.0.0-rc-603e6108-20241029)
@@ -472,6 +487,9 @@ importers:
       '@cprussin/tsconfig':
         specifier: 'catalog:'
         version: 3.0.1
+      '@pythnetwork/staking-sdk':
+        specifier: 'workspace:'
+        version: link:../../governance/pyth_staking_sdk
       '@svgr/webpack':
         specifier: 'catalog:'
         version: 8.1.0(typescript@5.6.3)
@@ -13587,6 +13605,9 @@ packages:
   dnum@2.13.1:
     resolution: {integrity: sha512-4oZ+BtlvNtKFJji1Fc5073LyJFvgioBQ0PNu/C+r1A8P09Yvka/aXYYD5bsUHMTUPEu01iv4bk+5nPQmt5AA8A==}
 
+  dnum@2.14.0:
+    resolution: {integrity: sha512-xyP+csC3QlQY0YCCcpJ6VApTJ35Gh+QN9L7gRstqaZc2VCtd1W1844AuYTy3FlZtWePJ/2hXYY250I0dd3qH6Q==}
+
   docker-modem@1.0.9:
     resolution: {integrity: sha512-lVjqCSCIAUDZPAZIeyM125HXfNvOmYYInciphNrLrylUtKyW66meAjSPXWchKVzoIYZx69TPnAepVSSkeawoIw==}
     engines: {node: '>= 0.8'}
@@ -19922,6 +19943,13 @@ packages:
       react: ^16.0.0 || ^17.0.0 || ^18.0.0
       react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
 
+  recharts@2.14.1:
+    resolution: {integrity: sha512-xtWulflkA+/xu4/QClBdtZYN30dbvTHjxjkh5XTMrH/CQ3WGDDPHHa/LLKCbgoqz0z3UaSH2/blV1i6VNMeh1g==}
+    engines: {node: '>=14'}
+    peerDependencies:
+      react: ^16.0.0 || ^17.0.0 || ^18.0.0
+      react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
+
   rechoir@0.6.2:
     resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
     engines: {node: '>= 0.10'}
@@ -45866,6 +45894,10 @@ snapshots:
     dependencies:
       from-exponential: 1.1.1
 
+  dnum@2.14.0:
+    dependencies:
+      from-exponential: 1.1.1
+
   docker-modem@1.0.9:
     dependencies:
       JSONStream: 1.3.2
@@ -57093,6 +57125,19 @@ snapshots:
       tiny-invariant: 1.3.3
       victory-vendor: 36.9.2
 
+  recharts@2.14.1(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029):
+    dependencies:
+      clsx: 2.1.1
+      eventemitter3: 4.0.7
+      lodash: 4.17.21
+      react: 19.0.0-rc-603e6108-20241029
+      react-dom: 19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029)
+      react-is: 18.3.1
+      react-smooth: 4.0.1(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)
+      recharts-scale: 0.4.5
+      tiny-invariant: 1.3.3
+      victory-vendor: 36.9.2
+
   rechoir@0.6.2:
     dependencies:
       resolve: 1.22.8

+ 2 - 0
pnpm-workspace.yaml

@@ -64,6 +64,7 @@ catalog:
   clsx: 2.1.1
   cryptocurrency-icons: 0.18.1
   css-loader: 7.1.2
+  dnum: 2.14.0
   eslint: 9.13.0
   framer-motion: 11.11.10
   jest: 29.7.0
@@ -80,6 +81,7 @@ catalog:
   react-aria: 3.36.0
   react-dom: 19.0.0-rc-603e6108-20241029
   react: 19.0.0-rc-603e6108-20241029
+  recharts: 2.14.1
   sass-loader: 16.0.3
   sass: 1.80.7
   storybook: 8.3.5