Selaa lähdekoodia

Merge pull request #2509 from pyth-network/cprussin/ui-50-fix-loading-jankiness

wip: add nextjs loading state
Connor Prussin 6 kuukautta sitten
vanhempi
sitoutus
676ee6c243
95 muutettua tiedostoa jossa 3755 lisäystä ja 3126 poistoa
  1. 4 0
      apps/insights/next.config.js
  2. 0 3
      apps/insights/src/app/layout.ts
  3. 1 0
      apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts
  4. 3 0
      apps/insights/src/app/price-feeds/[slug]/(main)/page.ts
  5. 1 0
      apps/insights/src/app/price-feeds/[slug]/@feedCountBadge/default.ts
  6. 1 0
      apps/insights/src/app/price-feeds/[slug]/@header/default.ts
  7. 3 1
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  8. 0 4
      apps/insights/src/app/price-feeds/[slug]/page.ts
  9. 1 0
      apps/insights/src/app/price-feeds/[slug]/publishers/loading.tsx
  10. 0 1
      apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx
  11. 0 11
      apps/insights/src/app/price-feeds/layout.ts
  12. 10 1
      apps/insights/src/app/price-feeds/page.ts
  13. 1 0
      apps/insights/src/app/publishers/[cluster]/[key]/(performance)/loading.ts
  14. 3 0
      apps/insights/src/app/publishers/[cluster]/[key]/(performance)/page.ts
  15. 1 2
      apps/insights/src/app/publishers/[cluster]/[key]/layout.ts
  16. 0 4
      apps/insights/src/app/publishers/[cluster]/[key]/page.ts
  17. 1 0
      apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/loading.tsx
  18. 0 1
      apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/page.ts
  19. 0 11
      apps/insights/src/app/publishers/layout.ts
  20. 9 1
      apps/insights/src/app/publishers/page.ts
  21. 12 0
      apps/insights/src/components/AssetClassBadge/index.tsx
  22. 0 29
      apps/insights/src/components/AssetClassTag/index.tsx
  23. 1 1
      apps/insights/src/components/EntityList/index.tsx
  24. 16 20
      apps/insights/src/components/Explain/index.tsx
  25. 0 91
      apps/insights/src/components/LayoutTransition/index.tsx
  26. 297 185
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  27. 58 32
      apps/insights/src/components/PriceComponentsCard/index.tsx
  28. 11 0
      apps/insights/src/components/PriceFeed/chart-page.module.scss
  29. 39 20
      apps/insights/src/components/PriceFeed/chart-page.tsx
  30. 32 0
      apps/insights/src/components/PriceFeed/feed-count-badge.tsx
  31. 20 0
      apps/insights/src/components/PriceFeed/get-feed.tsx
  32. 59 0
      apps/insights/src/components/PriceFeed/header.module.scss
  33. 272 0
      apps/insights/src/components/PriceFeed/header.tsx
  34. 0 66
      apps/insights/src/components/PriceFeed/layout.module.scss
  35. 29 197
      apps/insights/src/components/PriceFeed/layout.tsx
  36. 124 62
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  37. 82 126
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  38. 4 5
      apps/insights/src/components/PriceFeed/publishers.tsx
  39. 2 2
      apps/insights/src/components/PriceFeed/reference-data.tsx
  40. 1 1
      apps/insights/src/components/PriceFeedIcon/index.tsx
  41. 0 7
      apps/insights/src/components/PriceFeedTag/index.module.scss
  42. 44 91
      apps/insights/src/components/PriceFeedTag/index.tsx
  43. 10 55
      apps/insights/src/components/PriceFeeds/asset-class-table.tsx
  44. 27 36
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  45. 80 26
      apps/insights/src/components/PriceFeeds/index.tsx
  46. 39 48
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  47. 0 3
      apps/insights/src/components/Publisher/get-price-feeds.tsx
  48. 518 324
      apps/insights/src/components/Publisher/layout.tsx
  49. 2 2
      apps/insights/src/components/Publisher/performance.module.scss
  50. 203 118
      apps/insights/src/components/Publisher/performance.tsx
  51. 0 113
      apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx
  52. 0 83
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  53. 67 4
      apps/insights/src/components/Publisher/price-feeds.tsx
  54. 143 60
      apps/insights/src/components/Publisher/top-feeds-table.tsx
  55. 2 0
      apps/insights/src/components/Publishers/publishers-card.tsx
  56. 1 5
      apps/insights/src/components/Root/footer.tsx
  57. 10 13
      apps/insights/src/components/Root/header.tsx
  58. 37 43
      apps/insights/src/components/Root/index.tsx
  59. 44 65
      apps/insights/src/components/Root/mobile-menu.tsx
  60. 1 22
      apps/insights/src/components/Root/search-button.module.scss
  61. 379 15
      apps/insights/src/components/Root/search-button.tsx
  62. 0 468
      apps/insights/src/components/Root/search-dialog.tsx
  63. 77 74
      apps/insights/src/components/Root/support-drawer.tsx
  64. 4 43
      apps/insights/src/components/Root/tabs.tsx
  65. 17 43
      apps/insights/src/components/Tabs/index.tsx
  66. 0 60
      apps/insights/src/components/ZoomLayoutTransition/index.tsx
  67. 0 40
      apps/insights/src/hooks/use-price-feeds.tsx
  68. 5 10
      apps/insights/src/services/pyth.ts
  69. 3 0
      apps/insights/turbo.json
  70. 1 0
      packages/component-library/.storybook/main.ts
  71. 12 23
      packages/component-library/.storybook/preview.tsx
  72. 8 5
      packages/component-library/.storybook/storybook.module.scss
  73. 39 5
      packages/component-library/src/Button/index.stories.tsx
  74. 1 1
      packages/component-library/src/Card/index.tsx
  75. 0 43
      packages/component-library/src/Drawer/index.stories.tsx
  76. 38 12
      packages/component-library/src/Link/index.stories.tsx
  77. 4 1
      packages/component-library/src/Link/index.tsx
  78. 1 0
      packages/component-library/src/MainContent/index.module.scss
  79. 15 40
      packages/component-library/src/MainContent/index.tsx
  80. 130 2
      packages/component-library/src/ModalDialog/index.tsx
  81. 1 0
      packages/component-library/src/Paginator/index.module.scss
  82. 1 1
      packages/component-library/src/Select/index.stories.tsx
  83. 2 0
      packages/component-library/src/Spinner/index.tsx
  84. 1 1
      packages/component-library/src/TabList/index.stories.tsx
  85. 4 4
      packages/component-library/src/TabList/index.tsx
  86. 58 1
      packages/component-library/src/unstyled/Button/index.tsx
  87. 1 1
      packages/component-library/src/unstyled/Link/index.tsx
  88. 0 0
      packages/component-library/src/useAlert/index.module.scss
  89. 22 13
      packages/component-library/src/useAlert/index.stories.tsx
  90. 28 29
      packages/component-library/src/useAlert/index.tsx
  91. 29 17
      packages/component-library/src/useDrawer/index.module.scss
  92. 52 0
      packages/component-library/src/useDrawer/index.stories.tsx
  93. 146 85
      packages/component-library/src/useDrawer/index.tsx
  94. 347 91
      pnpm-lock.yaml
  95. 3 3
      pnpm-workspace.yaml

+ 4 - 0
apps/insights/next.config.js

@@ -3,6 +3,10 @@ const config = {
 
   pageExtensions: ["ts", "tsx", "mdx"],
 
+  experimental: {
+    useCache: true,
+  },
+
   logging: {
     fetches: {
       fullUrl: true,

+ 0 - 3
apps/insights/src/app/layout.ts

@@ -1,5 +1,2 @@
 export { Root as default } from "../components/Root";
 export { metadata, viewport } from "../metadata";
-
-export const dynamic = "error";
-export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts

@@ -0,0 +1 @@
+export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page";

+ 3 - 0
apps/insights/src/app/price-feeds/[slug]/(main)/page.ts

@@ -0,0 +1,3 @@
+export { ChartPage as default } from "../../../../components/PriceFeed/chart-page";
+
+export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/@feedCountBadge/default.ts

@@ -0,0 +1 @@
+export { FeedCountBadge as default } from "../../../../components/PriceFeed/feed-count-badge";

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/@header/default.ts

@@ -0,0 +1 @@
+export { PriceFeedHeader as default } from "../../../../components/PriceFeed/header";

+ 3 - 1
apps/insights/src/app/price-feeds/[slug]/layout.ts

@@ -1,11 +1,14 @@
 import type { Metadata } from "next";
 import { notFound } from "next/navigation";
+import type { ReactNode } from "react";
 
 import { Cluster, getFeeds } from "../../../services/pyth";
 
 export { PriceFeedLayout as default } from "../../../components/PriceFeed/layout";
 
 type Props = {
+  feedCountBadge: ReactNode;
+  header: ReactNode;
   params: Promise<{
     slug: string;
   }>;
@@ -29,5 +32,4 @@ export const generateMetadata = async ({
     : notFound();
 };
 
-export const dynamic = "error";
 export const revalidate = 3600;

+ 0 - 4
apps/insights/src/app/price-feeds/[slug]/page.ts

@@ -1,4 +0,0 @@
-export { ChartPage as default } from "../../../components/PriceFeed/chart-page";
-
-export const dynamic = "error";
-export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/publishers/loading.tsx

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

+ 0 - 1
apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx

@@ -1,4 +1,3 @@
 export { Publishers as default } from "../../../../components/PriceFeed/publishers";
 
-export const dynamic = "error";
 export const revalidate = 3600;

+ 0 - 11
apps/insights/src/app/price-feeds/layout.ts

@@ -1,11 +0,0 @@
-import type { Metadata } from "next";
-
-export { ZoomLayoutTransition as default } from "../../components/ZoomLayoutTransition";
-
-export const metadata: Metadata = {
-  title: {
-    default: "Price Feeds",
-    template: "%s | Price Feeds | Pyth Network Insights",
-  },
-  description: "Explore market data on the Pyth network.",
-};

+ 10 - 1
apps/insights/src/app/price-feeds/page.ts

@@ -1,4 +1,13 @@
+import type { Metadata } from "next";
+
 export { PriceFeeds as default } from "../../components/PriceFeeds";
 
-export const dynamic = "error";
 export const revalidate = 3600;
+
+export const metadata: Metadata = {
+  title: {
+    default: "Price Feeds",
+    template: "%s | Price Feeds | Pyth Network Insights",
+  },
+  description: "Explore market data on the Pyth network.",
+};

+ 1 - 0
apps/insights/src/app/publishers/[cluster]/[key]/(performance)/loading.ts

@@ -0,0 +1 @@
+export { PerformanceLoading as default } from "../../../../../components/Publisher/performance";

+ 3 - 0
apps/insights/src/app/publishers/[cluster]/[key]/(performance)/page.ts

@@ -0,0 +1,3 @@
+export { Performance as default } from "../../../../../components/Publisher/performance";
+
+export const revalidate = 3600;

+ 1 - 2
apps/insights/src/app/publishers/[cluster]/[key]/layout.ts

@@ -1,7 +1,7 @@
 import { lookup } from "@pythnetwork/known-publishers";
 import type { Metadata } from "next";
 
-export { PublishersLayout as default } from "../../../../components/Publisher/layout";
+export { PublisherLayout as default } from "../../../../components/Publisher/layout";
 
 type Props = {
   params: Promise<{
@@ -22,5 +22,4 @@ export const generateMetadata = async ({
   };
 };
 
-export const dynamic = "error";
 export const revalidate = 3600;

+ 0 - 4
apps/insights/src/app/publishers/[cluster]/[key]/page.ts

@@ -1,4 +0,0 @@
-export { Performance as default } from "../../../../components/Publisher/performance";
-
-export const dynamic = "error";
-export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/loading.tsx

@@ -0,0 +1 @@
+export { PriceFeedsLoading as default } from "../../../../../components/Publisher/price-feeds";

+ 0 - 1
apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/page.ts

@@ -1,4 +1,3 @@
 export { PriceFeeds as default } from "../../../../../components/Publisher/price-feeds";
 
-export const dynamic = "error";
 export const revalidate = 3600;

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

@@ -1,11 +0,0 @@
-import type { Metadata } from "next";
-
-export { ZoomLayoutTransition as default } from "../../components/ZoomLayoutTransition";
-
-export const metadata: Metadata = {
-  title: {
-    default: "Publishers",
-    template: "%s | Publishers | Pyth Network Insights",
-  },
-  description: "Explore publishers who contribute to the Pyth network.",
-};

+ 9 - 1
apps/insights/src/app/publishers/page.ts

@@ -1,4 +1,12 @@
+import type { Metadata } from "next";
 export { Publishers as default } from "../../components/Publishers";
 
-export const dynamic = "error";
 export const revalidate = 3600;
+
+export const metadata: Metadata = {
+  title: {
+    default: "Publishers",
+    template: "%s | Publishers | Pyth Network Insights",
+  },
+  description: "Explore publishers who contribute to the Pyth network.",
+};

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

@@ -0,0 +1,12 @@
+import { Badge } from "@pythnetwork/component-library/Badge";
+import type { ComponentProps } from "react";
+
+type Props = Omit<ComponentProps<typeof Badge>, "children"> & {
+  children: string;
+};
+
+export const AssetClassBadge = ({ children, ...props }: Props) => (
+  <Badge variant="neutral" style="outline" size="xs" {...props}>
+    {children.toUpperCase()}
+  </Badge>
+);

+ 0 - 29
apps/insights/src/components/AssetClassTag/index.tsx

@@ -1,29 +0,0 @@
-import { Badge } from "@pythnetwork/component-library/Badge";
-import type { ComponentProps } from "react";
-
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-
-type Props = ComponentProps<typeof Badge> & {
-  symbol: string;
-};
-
-export const AssetClassTag = ({ symbol }: Props) => {
-  const feed = usePriceFeeds().get(symbol);
-
-  if (feed) {
-    return (
-      <Badge variant="neutral" style="outline" size="xs">
-        {feed.assetClass.toUpperCase()}
-      </Badge>
-    );
-  } else {
-    throw new NoSuchFeedError(symbol);
-  }
-};
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

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

@@ -55,7 +55,7 @@ export const EntityList = <T extends string>({
     {...props}
   >
     {isLoading ? (
-      <GridListItem className={styles.entityItem ?? ""}>
+      <GridListItem className={styles.entityItem ?? ""} textValue="Loading">
         <div className={styles.itemHeader}>{headerLoadingSkeleton}</div>
         <dl className={styles.itemDetails}>
           {fields.map((field) => (

+ 16 - 20
apps/insights/src/components/Explain/index.tsx

@@ -1,6 +1,5 @@
 import { Info } from "@phosphor-icons/react/dist/ssr/Info";
 import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
-import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
 import { Button } from "@pythnetwork/component-library/Button";
 import type { ComponentProps, ReactNode } from "react";
 
@@ -14,24 +13,21 @@ type Props = {
 
 export const Explain = ({ size, title, children }: Props) => (
   <div className={styles.explain}>
-    <AlertTrigger>
-      <Button
-        className={styles.trigger ?? ""}
-        variant="ghost"
-        size={size}
-        beforeIcon={(props) => <Info weight="fill" {...props} />}
-        rounded
-        hideText
-      >
-        Explain {title}
-      </Button>
-      <Alert
-        title={title}
-        icon={<Lightbulb />}
-        bodyClassName={styles.description}
-      >
-        {children}
-      </Alert>
-    </AlertTrigger>
+    <Button
+      className={styles.trigger ?? ""}
+      variant="ghost"
+      size={size}
+      beforeIcon={(props) => <Info weight="fill" {...props} />}
+      rounded
+      hideText
+      alert={{
+        title,
+        icon: <Lightbulb />,
+        bodyClassName: styles.description,
+        contents: children,
+      }}
+    >
+      Explain {title}
+    </Button>
   </div>
 );

+ 0 - 91
apps/insights/src/components/LayoutTransition/index.tsx

@@ -1,91 +0,0 @@
-"use client";
-
-import type { TargetAndTransition, Target } from "motion/react";
-import { AnimatePresence, motion } from "motion/react";
-import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
-import { useSelectedLayoutSegment } from "next/navigation";
-import type { ReactNode, ComponentProps } from "react";
-import { useContext, useEffect, useRef } from "react";
-
-type OwnProps = {
-  children: ReactNode;
-  variants?: Record<
-    string,
-    | TargetAndTransition
-    | ((
-        custom: VariantArg,
-        current: Target,
-        velocity: Target,
-      ) => TargetAndTransition | string)
-  >;
-};
-
-export type VariantArg = {
-  segment: ReturnType<typeof useSelectedLayoutSegment>;
-  prevSegment: ReturnType<typeof useSelectedLayoutSegment>;
-};
-
-type Props = Omit<ComponentProps<typeof motion.div>, keyof OwnProps> & OwnProps;
-
-export const LayoutTransition = ({ children, ...props }: Props) => {
-  const segment = useSelectedLayoutSegment();
-  const prevSegment =
-    useRef<ReturnType<typeof useSelectedLayoutSegment>>(segment);
-  const nextSegment =
-    useRef<ReturnType<typeof useSelectedLayoutSegment>>(segment);
-
-  useEffect(() => {
-    nextSegment.current = segment;
-  }, [segment]);
-
-  const updatePrevSegment = () => {
-    prevSegment.current = nextSegment.current;
-  };
-
-  return (
-    <AnimatePresence
-      mode="popLayout"
-      initial={false}
-      onExitComplete={updatePrevSegment}
-      custom={{ segment, prevSegment: prevSegment.current }}
-    >
-      <motion.div
-        key={segment}
-        custom={{ segment, prevSegment: prevSegment.current }}
-        {...props}
-      >
-        <FrozenRouter>{children}</FrozenRouter>
-      </motion.div>
-    </AnimatePresence>
-  );
-};
-
-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;
-};

+ 297 - 185
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -1,17 +1,22 @@
 import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
 import { Flask } from "@phosphor-icons/react/dist/ssr/Flask";
+import { useLogger } from "@pythnetwork/app-logger";
+import type { Props as ButtonProps } from "@pythnetwork/component-library/Button";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
-import { Drawer } from "@pythnetwork/component-library/Drawer";
 import { InfoBox } from "@pythnetwork/component-library/InfoBox";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Spinner } from "@pythnetwork/component-library/Spinner";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { Table } from "@pythnetwork/component-library/Table";
+import type { Button as UnstyledButton } from "@pythnetwork/component-library/unstyled/Button";
+import { useDrawer } from "@pythnetwork/component-library/useDrawer";
+import { useMountEffect } from "@react-hookz/web";
 import dynamic from "next/dynamic";
 import { useRouter } from "next/navigation";
+import { useQueryState, parseAsString } from "nuqs";
 import type { ReactNode } from "react";
-import { Suspense, useState, useRef, useCallback, useMemo } from "react";
+import { Suspense, useState, useCallback, useMemo, useTransition } from "react";
 import {
   RouterProvider,
   useDateFormatter,
@@ -37,10 +42,8 @@ const LineChart = dynamic(
   },
 );
 
-type Props = {
-  onClose: () => void;
-  title: ReactNode;
-  headingExtra?: ReactNode | undefined;
+type PriceComponent = {
+  name: ReactNode;
   publisherKey: string;
   symbol: string;
   displaySymbol: string;
@@ -50,50 +53,279 @@ type Props = {
   rank: number | undefined;
   status: Status;
   identifiesPublisher?: boolean | undefined;
-  navigateHref: string;
-  firstEvaluation: Date;
+  firstEvaluation?: Date | undefined;
   cluster: Cluster;
 };
 
-export const PriceComponentDrawer = ({
-  publisherKey,
-  onClose,
-  symbol,
-  displaySymbol,
-  assetClass,
-  feedKey,
-  score,
-  rank,
-  title,
-  status,
-  headingExtra,
-  navigateHref,
-  firstEvaluation,
-  cluster,
+export const usePriceComponentDrawer = ({
+  components,
   identifiesPublisher,
-}: Props) => {
-  const goToPriceFeedPageOnClose = useRef<boolean>(false);
-  const [isFeedDrawerOpen, setIsFeedDrawerOpen] = useState(true);
+}: {
+  components: PriceComponent[];
+  identifiesPublisher?: boolean | undefined;
+}) => {
+  const logger = useLogger();
+  const drawer = useDrawer();
   const router = useRouter();
-  const handleClose = useCallback(
-    (isOpen: boolean) => {
-      if (!isOpen) {
-        setIsFeedDrawerOpen(false);
+  const [isRouting, startTransition] = useTransition();
+
+  const navigate = useCallback(
+    (route: string) => {
+      startTransition(() => {
+        router.push(route);
+        drawer.close().catch((error: unknown) => {
+          logger.error(error);
+        });
+      });
+    },
+    [router, startTransition, logger, drawer],
+  );
+
+  const [selectedComponentId, setSelectedComponentId] = useQueryState(
+    identifiesPublisher ? "publisher" : "priceFeed",
+    parseAsString.withDefault(""),
+  );
+
+  const updateSelectedComponentId = useCallback(
+    (componentId: string) => {
+      if (!isRouting) {
+        setSelectedComponentId(componentId).catch((error: unknown) => {
+          logger.error(error);
+        });
       }
     },
-    [setIsFeedDrawerOpen],
+    [setSelectedComponentId, isRouting, logger],
   );
-  const handleCloseFinish = useCallback(() => {
-    if (goToPriceFeedPageOnClose.current) {
-      router.push(navigateHref);
-    } else {
-      onClose();
+
+  const clearSelectedComponent = useCallback(() => {
+    updateSelectedComponentId("");
+  }, [updateSelectedComponentId]);
+
+  useMountEffect(() => {
+    if (selectedComponentId) {
+      const component = components.find(
+        (component) =>
+          component[identifiesPublisher ? "publisherKey" : "feedKey"],
+      );
+      if (component) {
+        openDrawer(component);
+      }
     }
-  }, [router, onClose, navigateHref]);
-  const handleOpenFeed = useCallback(() => {
-    goToPriceFeedPageOnClose.current = true;
-    setIsFeedDrawerOpen(false);
-  }, [setIsFeedDrawerOpen]);
+  });
+
+  const openDrawer = useCallback(
+    (component: PriceComponent) => {
+      drawer.open({
+        title: component.name,
+        className: styles.priceComponentDrawer ?? "",
+        bodyClassName: styles.priceComponentDrawerBody ?? "",
+        onClose: clearSelectedComponent,
+        headingExtra: (
+          <RouterProvider navigate={navigate}>
+            <HeadingExtra
+              identifiesPublisher={identifiesPublisher}
+              status={component.status}
+              cluster={component.cluster}
+              publisherKey={component.publisherKey}
+              symbol={component.symbol}
+            />
+          </RouterProvider>
+        ),
+        headingAfter: (
+          <div className={styles.badges}>
+            <StatusComponent status={component.status} />
+          </div>
+        ),
+        contents: (
+          <RouterProvider navigate={navigate}>
+            {component.cluster === Cluster.PythtestConformance && (
+              <InfoBox
+                icon={<Flask />}
+                header={`This publisher is in test`}
+                className={styles.testFeedMessage}
+              >
+                This is a test publisher. Its prices are not included in the
+                Pyth aggregate price for {component.displaySymbol}.
+              </InfoBox>
+            )}
+            <div className={styles.stats}>
+              <StatCard
+                nonInteractive
+                header={
+                  <>
+                    Aggregated <PriceName assetClass={component.assetClass} />
+                  </>
+                }
+                small
+                stat={
+                  <LivePrice
+                    feedKey={component.feedKey}
+                    cluster={component.cluster}
+                  />
+                }
+              />
+              <StatCard
+                nonInteractive
+                header={
+                  <>
+                    Publisher <PriceName assetClass={component.assetClass} />
+                  </>
+                }
+                variant="primary"
+                small
+                stat={
+                  <LivePrice
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                    cluster={component.cluster}
+                  />
+                }
+              />
+              <StatCard
+                nonInteractive
+                header="Publisher Confidence"
+                small
+                stat={
+                  <LiveConfidence
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                    cluster={component.cluster}
+                  />
+                }
+              />
+              <StatCard
+                nonInteractive
+                header="Last Slot"
+                small
+                stat={
+                  <LiveComponentValue
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                    field="publishSlot"
+                    cluster={component.cluster}
+                  />
+                }
+              />
+              <StatCard
+                nonInteractive
+                header="Score"
+                small
+                stat={
+                  component.score ? (
+                    <Score fill score={component.score} />
+                  ) : (
+                    <></>
+                  )
+                }
+              />
+              <StatCard
+                nonInteractive
+                header="Quality Rank"
+                small
+                stat={component.rank ?? <></>}
+              />
+            </div>
+            {component.firstEvaluation && (
+              <ScoreBreakdown
+                firstEvaluation={component.firstEvaluation}
+                cluster={component.cluster}
+                publisherKey={component.publisherKey}
+                symbol={component.symbol}
+              />
+            )}
+          </RouterProvider>
+        ),
+      });
+    },
+    [clearSelectedComponent, drawer, identifiesPublisher, navigate],
+  );
+
+  const selectComponent = useCallback(
+    (component: PriceComponent) => {
+      updateSelectedComponentId(
+        component[identifiesPublisher ? "publisherKey" : "feedKey"],
+      );
+      openDrawer(component);
+    },
+    [updateSelectedComponentId, openDrawer, identifiesPublisher],
+  );
+
+  return { selectComponent };
+};
+
+type HeadingExtraProps = {
+  status: Status;
+  identifiesPublisher?: boolean | undefined;
+  cluster: Cluster;
+  publisherKey: string;
+  symbol: string;
+};
+
+const HeadingExtra = ({ status, ...props }: HeadingExtraProps) => {
+  return (
+    <>
+      <div className={styles.bigScreenBadges}>
+        <StatusComponent status={status} />
+      </div>
+      <OpenButton
+        variant="ghost"
+        hideText
+        beforeIcon={ArrowSquareOut}
+        rounded
+        className={styles.ghostOpenButton ?? ""}
+        {...props}
+      />
+      <OpenButton
+        variant="outline"
+        className={styles.outlineOpenButton ?? ""}
+        {...props}
+      />
+    </>
+  );
+};
+
+type OpenButtonProps = Omit<ButtonProps<typeof UnstyledButton>, "children"> & {
+  identifiesPublisher?: boolean | undefined;
+  cluster: Cluster;
+  publisherKey: string;
+  symbol: string;
+};
+
+const OpenButton = ({
+  identifiesPublisher,
+  cluster,
+  publisherKey,
+  symbol,
+  ...props
+}: OpenButtonProps) => {
+  const href = useMemo(
+    () =>
+      identifiesPublisher
+        ? `/publishers/${ClusterToName[cluster]}/${publisherKey}`
+        : `/price-feeds/${encodeURIComponent(symbol)}`,
+    [identifiesPublisher, cluster, publisherKey, symbol],
+  );
+
+  return (
+    <Button size="sm" href={href} {...props}>
+      Open {identifiesPublisher ? "Publisher" : "Feed"}
+    </Button>
+  );
+};
+
+type ScoreBreakdownProps = {
+  firstEvaluation: Date;
+  cluster: Cluster;
+  publisherKey: string;
+  symbol: string;
+};
+
+const ScoreBreakdown = ({
+  firstEvaluation,
+  cluster,
+  publisherKey,
+  symbol,
+}: ScoreBreakdownProps) => {
   const { selectedPeriod, setSelectedPeriod, evaluationPeriods } =
     useEvaluationPeriods(firstEvaluation);
   const scoreHistoryState = useData(
@@ -102,152 +334,32 @@ export const PriceComponentDrawer = ({
   );
 
   return (
-    <Drawer
-      onOpenChange={handleClose}
-      onCloseFinish={handleCloseFinish}
-      title={title}
-      headingExtra={
-        <>
-          <div className={styles.bigScreenBadges}>
-            {headingExtra}
-            <StatusComponent status={status} />
-          </div>
-          <RouterProvider navigate={handleOpenFeed}>
-            <Button
-              size="sm"
-              variant="ghost"
-              href={navigateHref}
-              hideText
-              beforeIcon={ArrowSquareOut}
-              rounded
-              className={styles.ghostOpenButton ?? ""}
-            >
-              Open {identifiesPublisher ? "Publisher" : "Feed"}
-            </Button>
-            <Button
-              size="sm"
-              variant="outline"
-              href={navigateHref}
-              className={styles.outlineOpenButton ?? ""}
-            >
-              Open {identifiesPublisher ? "Publisher" : "Feed"}
-            </Button>
-          </RouterProvider>
-        </>
-      }
-      headingAfter={
-        <div className={styles.badges}>
-          {headingExtra}
-          <StatusComponent status={status} />
-        </div>
+    <Card
+      title="Score Breakdown"
+      nonInteractive
+      className={styles.rankingBreakdown}
+      toolbar={
+        <Select
+          size="sm"
+          variant="outline"
+          hideLabel
+          label="Evaluation Period"
+          selectedKey={selectedPeriod.label}
+          onSelectionChange={(label) => {
+            const evaluationPeriod = evaluationPeriods.find(
+              (period) => period.label === label,
+            );
+            if (evaluationPeriod) {
+              setSelectedPeriod(evaluationPeriod);
+            }
+          }}
+          options={evaluationPeriods.map(({ label }) => label)}
+          placement="bottom end"
+        />
       }
-      isOpen={isFeedDrawerOpen}
-      className={styles.priceComponentDrawer ?? ""}
-      bodyClassName={styles.priceComponentDrawerBody ?? ""}
     >
-      {cluster === Cluster.PythtestConformance && (
-        <InfoBox
-          icon={<Flask />}
-          header={`This publisher is in test`}
-          className={styles.testFeedMessage}
-        >
-          This is a test publisher. Its prices are not included in the Pyth
-          aggregate price for {displaySymbol}.
-        </InfoBox>
-      )}
-      <div className={styles.stats}>
-        <StatCard
-          nonInteractive
-          header={
-            <>
-              Aggregated <PriceName assetClass={assetClass} />
-            </>
-          }
-          small
-          stat={<LivePrice feedKey={feedKey} cluster={cluster} />}
-        />
-        <StatCard
-          nonInteractive
-          header={
-            <>
-              Publisher <PriceName assetClass={assetClass} />
-            </>
-          }
-          variant="primary"
-          small
-          stat={
-            <LivePrice
-              feedKey={feedKey}
-              publisherKey={publisherKey}
-              cluster={cluster}
-            />
-          }
-        />
-        <StatCard
-          nonInteractive
-          header="Publisher Confidence"
-          small
-          stat={
-            <LiveConfidence
-              feedKey={feedKey}
-              publisherKey={publisherKey}
-              cluster={cluster}
-            />
-          }
-        />
-        <StatCard
-          nonInteractive
-          header="Last Slot"
-          small
-          stat={
-            <LiveComponentValue
-              feedKey={feedKey}
-              publisherKey={publisherKey}
-              field="publishSlot"
-              cluster={cluster}
-            />
-          }
-        />
-        <StatCard
-          nonInteractive
-          header="Score"
-          small
-          stat={score ? <Score fill score={score} /> : <></>}
-        />
-        <StatCard
-          nonInteractive
-          header="Quality Rank"
-          small
-          stat={rank ?? <></>}
-        />
-      </div>
-      <Card
-        title="Score Breakdown"
-        nonInteractive
-        className={styles.rankingBreakdown}
-        toolbar={
-          <Select
-            size="sm"
-            variant="outline"
-            hideLabel
-            label="Evaluation Period"
-            selectedKey={selectedPeriod.label}
-            onSelectionChange={(label) => {
-              const evaluationPeriod = evaluationPeriods.find(
-                (period) => period.label === label,
-              );
-              if (evaluationPeriod) {
-                setSelectedPeriod(evaluationPeriod);
-              }
-            }}
-            options={evaluationPeriods.map(({ label }) => label)}
-            placement="bottom end"
-          />
-        }
-      >
-        <ScoreHistory state={scoreHistoryState} />
-      </Card>
-    </Drawer>
+      <ScoreHistory state={scoreHistoryState} />
+    </Card>
   );
 };
 

+ 58 - 32
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -35,6 +35,7 @@ import { EvaluationTime } from "../Explanations";
 import { FormattedNumber } from "../FormattedNumber";
 import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices";
 import { NoResults } from "../NoResults";
+import { usePriceComponentDrawer } from "../PriceComponentDrawer";
 import { PriceName } from "../PriceName";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
@@ -44,21 +45,33 @@ const SCORE_WIDTH = 32;
 
 type Props<U extends string, T extends PriceComponent & Record<U, unknown>> = {
   className?: string | undefined;
-  priceComponents: T[];
-  metricsTime?: Date | undefined;
   nameLoadingSkeleton: ReactNode;
   label: string;
   searchPlaceholder: string;
-  onPriceComponentAction: (component: T) => void;
   toolbarExtra?: ReactNode;
   assetClass?: string | undefined;
   extraColumns?: ColumnConfig<U>[] | undefined;
   nameWidth?: number | undefined;
-};
+  identifiesPublisher?: boolean | undefined;
+} & (
+  | {
+      isLoading: true;
+    }
+  | {
+      isLoading?: false | undefined;
+      priceComponents: T[];
+      metricsTime?: Date | undefined;
+    }
+);
 
-type PriceComponent = {
+export type PriceComponent = {
   id: string;
   score: number | undefined;
+  rank: number | undefined;
+  symbol: string;
+  displaySymbol: string;
+  firstEvaluation?: Date | undefined;
+  assetClass: string;
   uptimeScore: number | undefined;
   deviationScore: number | undefined;
   stalledScore: number | undefined;
@@ -73,31 +86,45 @@ type PriceComponent = {
 export const PriceComponentsCard = <
   U extends string,
   T extends PriceComponent & Record<U, unknown>,
->({
-  priceComponents,
-  onPriceComponentAction,
-  ...props
-}: Props<U, T>) => (
-  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPriceComponentsCard
-      priceComponents={priceComponents}
-      onPriceComponentAction={onPriceComponentAction}
-      {...props}
-    />
-  </Suspense>
-);
+>(
+  props: Props<U, T>,
+) => {
+  if (props.isLoading) {
+    return <PriceComponentsCardContents {...props} />;
+  } else {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { isLoading, priceComponents, ...otherProps } = props;
+    return (
+      <Suspense
+        fallback={<PriceComponentsCardContents isLoading {...otherProps} />}
+      >
+        <ResolvedPriceComponentsCard
+          priceComponents={priceComponents}
+          {...otherProps}
+        />
+      </Suspense>
+    );
+  }
+};
 
 export const ResolvedPriceComponentsCard = <
   U extends string,
   T extends PriceComponent & Record<U, unknown>,
 >({
   priceComponents,
-  onPriceComponentAction,
+  identifiesPublisher,
   ...props
-}: Props<U, T>) => {
+}: Omit<Props<U, T>, "isLoading"> & {
+  priceComponents: T[];
+  metricsTime?: Date | undefined;
+}) => {
   const logger = useLogger();
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const { selectComponent } = usePriceComponentDrawer({
+    components: priceComponents,
+    identifiesPublisher,
+  });
   const [status, setStatus] = useQueryState(
     "status",
     parseAsStringEnum(["", ...Object.values(STATUS_NAMES)]).withDefault(""),
@@ -186,6 +213,9 @@ export const ResolvedPriceComponentsCard = <
       paginatedItems.map((component) => ({
         id: component.id,
         nameAsString: component.nameAsString,
+        onAction: () => {
+          selectComponent(component);
+        },
         data: {
           name: component.name,
           ...Object.fromEntries(
@@ -239,11 +269,8 @@ export const ResolvedPriceComponentsCard = <
           ),
           status: <StatusComponent status={component.status} />,
         },
-        onAction: () => {
-          onPriceComponentAction(component);
-        },
       })),
-    [paginatedItems, onPriceComponentAction, props.extraColumns],
+    [paginatedItems, props.extraColumns, selectComponent],
   );
 
   const updateStatus = useCallback(
@@ -294,7 +321,6 @@ type PriceComponentsCardProps<
 > = Pick<
   Props<U, T>,
   | "className"
-  | "metricsTime"
   | "nameLoadingSkeleton"
   | "label"
   | "searchPlaceholder"
@@ -307,6 +333,7 @@ type PriceComponentsCardProps<
     | { isLoading: true }
     | {
         isLoading?: false;
+        metricsTime?: Date | undefined;
         numResults: number;
         search: string;
         sortDescriptor: SortDescriptor;
@@ -331,7 +358,6 @@ export const PriceComponentsCardContents = <
   T extends PriceComponent & Record<U, unknown>,
 >({
   className,
-  metricsTime,
   nameLoadingSkeleton,
   label,
   searchPlaceholder,
@@ -347,11 +373,9 @@ export const PriceComponentsCardContents = <
       title={
         <>
           <span>{label}</span>
-          {!props.isLoading && (
-            <Badge style="filled" variant="neutral" size="md">
-              {props.numResults}
-            </Badge>
-          )}
+          <Badge style="filled" variant="neutral" size="md">
+            {!props.isLoading && props.numResults}
+          </Badge>
         </>
       }
       toolbar={
@@ -501,7 +525,9 @@ export const PriceComponentsCardContents = <
                       <b>Unranked</b> feeds have not yet been evaluated by Pyth
                     </li>
                   </ul>
-                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                  {!props.isLoading && props.metricsTime && (
+                    <EvaluationTime scoreTime={props.metricsTime} />
+                  )}
                 </Explain>
               </>
             ),

+ 11 - 0
apps/insights/src/components/PriceFeed/chart-page.module.scss

@@ -6,6 +6,17 @@
     height: theme.spacing(140);
     border-radius: theme.border-radius("xl");
     overflow: hidden;
+
+    .spinnerContainer {
+      width: 100%;
+      height: 100%;
+      display: grid;
+      place-content: center;
+
+      .spinner {
+        font-size: theme.spacing(12);
+      }
+    }
   }
 }
 

+ 39 - 20
apps/insights/src/components/PriceFeed/chart-page.tsx

@@ -1,11 +1,11 @@
 import { Info } from "@phosphor-icons/react/dist/ssr/Info";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Link } from "@pythnetwork/component-library/Link";
-import { notFound } from "next/navigation";
+import { Spinner } from "@pythnetwork/component-library/Spinner";
 
 import { Chart } from "./chart";
 import styles from "./chart-page.module.scss";
-import { Cluster, getFeeds } from "../../services/pyth";
+import { getFeed } from "./get-feed";
 
 type Props = {
   params: Promise<{
@@ -13,25 +13,44 @@ type Props = {
   }>;
 };
 
-export const ChartPage = async ({ params }: Props) => {
-  const [{ slug }, feeds] = await Promise.all([
-    params,
-    getFeeds(Cluster.Pythnet),
-  ]);
-  const symbol = decodeURIComponent(slug);
-  const feed = feeds.find((item) => item.symbol === symbol);
+export const ChartPage = async ({ params }: Props) => (
+  <ChartPageImpl {...await getFeed(params)} />
+);
 
-  return feed ? (
-    <Card title="Chart" className={styles.chartCard}>
-      <div className={styles.chart}>
-        <Chart symbol={symbol} feedId={feed.product.price_account} />
-      </div>
-      <Disclaimer symbol={symbol} displaySymbol={feed.product.display_symbol} />
-    </Card>
-  ) : (
-    notFound()
-  );
-};
+export const ChartPageLoading = () => <ChartPageImpl isLoading />;
+
+type ChartPageImplProps =
+  | { isLoading: true }
+  | (Awaited<ReturnType<typeof getFeed>> & {
+      isLoading?: false | undefined;
+    });
+
+const ChartPageImpl = (props: ChartPageImplProps) => (
+  <Card title="Chart" className={styles.chartCard}>
+    <div className={styles.chart}>
+      {props.isLoading ? (
+        <div className={styles.spinnerContainer}>
+          <Spinner
+            label="Loading chart"
+            isIndeterminate
+            className={styles.spinner ?? ""}
+          />
+        </div>
+      ) : (
+        <Chart
+          symbol={props.symbol}
+          feedId={props.feed.product.price_account}
+        />
+      )}
+    </div>
+    {!props.isLoading && (
+      <Disclaimer
+        symbol={props.symbol}
+        displaySymbol={props.feed.product.display_symbol}
+      />
+    )}
+  </Card>
+);
 
 type DisclaimerProps = {
   displaySymbol: string;

+ 32 - 0
apps/insights/src/components/PriceFeed/feed-count-badge.tsx

@@ -0,0 +1,32 @@
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Suspense } from "react";
+
+import { getFeed } from "./get-feed";
+import { Cluster } from "../../services/pyth";
+import { LiveValue } from "../LivePrices";
+
+type Props = {
+  params: Promise<{
+    slug: string;
+  }>;
+};
+
+export const FeedCountBadge = ({ params }: Props) => (
+  <Suspense>
+    <FeedCountBadgeImpl params={params} />
+  </Suspense>
+);
+
+const FeedCountBadgeImpl = async ({ params }: Props) => {
+  const { feed } = await getFeed(params);
+  return (
+    <Badge size="xs" style="filled" variant="neutral">
+      <LiveValue
+        feedKey={feed.product.price_account}
+        field="numComponentPrices"
+        defaultValue={feed.price.numComponentPrices}
+        cluster={Cluster.Pythnet}
+      />
+    </Badge>
+  );
+};

+ 20 - 0
apps/insights/src/components/PriceFeed/get-feed.tsx

@@ -0,0 +1,20 @@
+import { notFound } from "next/navigation";
+
+import { Cluster, getFeeds } from "../../services/pyth";
+
+export const getFeed = async (params: Promise<{ slug: string }>) => {
+  "use cache";
+
+  const [{ slug }, feeds] = await Promise.all([params, getPythnetFeeds()]);
+  const symbol = decodeURIComponent(slug);
+  return {
+    feeds,
+    feed: feeds.find((item) => item.symbol === symbol) ?? notFound(),
+    symbol,
+  } as const;
+};
+
+const getPythnetFeeds = async () => {
+  "use cache";
+  return getFeeds(Cluster.Pythnet);
+};

+ 59 - 0
apps/insights/src/components/PriceFeed/header.module.scss

@@ -0,0 +1,59 @@
+@use "@pythnetwork/component-library/theme";
+
+.header {
+  margin-bottom: theme.spacing(4);
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(4);
+
+  @include theme.max-width;
+
+  @include theme.breakpoint("sm") {
+    margin-bottom: theme.spacing(6);
+    gap: theme.spacing(6);
+  }
+
+  .headerRow {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(2);
+    justify-content: space-between;
+
+    @include theme.breakpoint("sm") {
+      flex-flow: row nowrap;
+      align-items: center;
+      gap: unset;
+    }
+  }
+
+  .rightGroup {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: stretch;
+    gap: theme.spacing(4);
+
+    & > * {
+      flex: 1 1 0;
+      width: 0;
+
+      @include theme.breakpoint("sm") {
+        flex: unset;
+        width: unset;
+      }
+    }
+  }
+
+  .priceFeedSelect {
+    display: none;
+
+    @include theme.breakpoint("sm") {
+      display: block;
+    }
+  }
+
+  .priceFeedTag {
+    @include theme.breakpoint("sm") {
+      display: none;
+    }
+  }
+}

+ 272 - 0
apps/insights/src/components/PriceFeed/header.tsx

@@ -0,0 +1,272 @@
+import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
+import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { Suspense } from "react";
+
+import styles from "./header.module.scss";
+import { PriceFeedSelect } from "./price-feed-select";
+import { ReferenceData } from "./reference-data";
+import { Cluster } from "../../services/pyth";
+import { AssetClassBadge } from "../AssetClassBadge";
+import { Cards } from "../Cards";
+import { Explain } from "../Explain";
+import { FeedKey } from "../FeedKey";
+import { LivePrice, LiveConfidence, LiveLastUpdated } from "../LivePrices";
+import {
+  YesterdaysPricesProvider,
+  PriceFeedChangePercent,
+} from "../PriceFeedChangePercent";
+import { PriceFeedIcon } from "../PriceFeedIcon";
+import { PriceFeedTag } from "../PriceFeedTag";
+import { PriceName } from "../PriceName";
+import { getFeed } from "./get-feed";
+
+type Props = {
+  params: Promise<{
+    slug: string;
+  }>;
+};
+
+export const PriceFeedHeader = ({ params }: Props) => (
+  <Suspense fallback={<PriceFeedHeaderImpl isLoading />}>
+    <ResolvedPriceFeedHeader params={params} />
+  </Suspense>
+);
+
+const ResolvedPriceFeedHeader = async ({ params }: Props) => (
+  <PriceFeedHeaderImpl {...await getFeed(params)} />
+);
+
+type PriceFeedHeaderImplProps =
+  | { isLoading: true }
+  | ({
+      isLoading?: false | undefined;
+    } & Awaited<ReturnType<typeof getFeed>>);
+
+const PriceFeedHeaderImpl = (props: PriceFeedHeaderImplProps) => (
+  <section className={styles.header}>
+    <div className={styles.headerRow}>
+      <Breadcrumbs
+        label="Breadcrumbs"
+        items={[
+          { href: "/", label: "Home" },
+          { href: "/price-feeds", label: "Price Feeds" },
+          {
+            label: props.isLoading ? (
+              <Skeleton width={30} />
+            ) : (
+              props.feed.product.display_symbol
+            ),
+          },
+        ]}
+      />
+      {props.isLoading ? (
+        <Skeleton width={15} />
+      ) : (
+        <AssetClassBadge>{props.feed.product.asset_type}</AssetClassBadge>
+      )}
+    </div>
+    <div className={styles.headerRow}>
+      <PriceFeedSelect
+        className={styles.priceFeedSelect}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              feeds: props.feeds
+                .filter((item) => item.symbol !== props.symbol)
+                .map((item) => ({
+                  symbol: item.symbol,
+                  assetClass: item.product.asset_type,
+                  description: item.product.description,
+                  displaySymbol: item.product.display_symbol,
+                  key: item.product.price_account,
+                  icon: (
+                    <PriceFeedIcon
+                      assetClass={item.product.asset_type}
+                      symbol={item.symbol}
+                    />
+                  ),
+                })),
+            })}
+      >
+        <PriceFeedTag
+          {...(props.isLoading
+            ? { isLoading: true }
+            : {
+                description: props.feed.product.description,
+                displaySymbol: props.feed.product.display_symbol,
+                icon: (
+                  <PriceFeedIcon
+                    assetClass={props.feed.product.asset_type}
+                    symbol={props.feed.symbol}
+                  />
+                ),
+              })}
+        />
+      </PriceFeedSelect>
+      <PriceFeedTag
+        className={styles.priceFeedTag}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              description: props.feed.product.description,
+              displaySymbol: props.feed.product.display_symbol,
+              icon: (
+                <PriceFeedIcon
+                  assetClass={props.feed.product.asset_type}
+                  symbol={props.feed.symbol}
+                />
+              ),
+            })}
+      />
+      <div className={styles.rightGroup}>
+        {props.isLoading ? (
+          <Skeleton width={30} />
+        ) : (
+          <FeedKey
+            className={styles.feedKey ?? ""}
+            feedKey={props.feed.product.price_account}
+          />
+        )}
+        <Button
+          variant="outline"
+          size="sm"
+          beforeIcon={ListDashes}
+          isPending={props.isLoading}
+          {...(!props.isLoading && {
+            drawer: {
+              fill: true,
+              title: "Reference Data",
+              contents: (
+                <ReferenceData
+                  feed={{
+                    symbol: props.feed.symbol,
+                    feedKey: props.feed.product.price_account,
+                    assetClass: props.feed.product.asset_type,
+                    base: props.feed.product.base,
+                    description: props.feed.product.description,
+                    country: props.feed.product.country,
+                    quoteCurrency: props.feed.product.quote_currency,
+                    tenor: props.feed.product.tenor,
+                    cmsSymbol: props.feed.product.cms_symbol,
+                    cqsSymbol: props.feed.product.cqs_symbol,
+                    nasdaqSymbol: props.feed.product.nasdaq_symbol,
+                    genericSymbol: props.feed.product.generic_symbol,
+                    weeklySchedule: props.feed.product.weekly_schedule,
+                    schedule: props.feed.product.schedule,
+                    contractId: props.feed.product.contract_id,
+                    displaySymbol: props.feed.product.display_symbol,
+                    exponent: props.feed.price.exponent,
+                    numComponentPrices: props.feed.price.numComponentPrices,
+                    numQuoters: props.feed.price.numQuoters,
+                    minPublishers: props.feed.price.minPublishers,
+                    lastSlot: props.feed.price.lastSlot,
+                    validSlot: props.feed.price.validSlot,
+                  }}
+                />
+              ),
+            },
+          })}
+        >
+          Reference Data
+        </Button>
+      </div>
+    </div>
+    <Cards>
+      <StatCard
+        variant="primary"
+        header={
+          props.isLoading ? (
+            <Skeleton width={30} />
+          ) : (
+            <>
+              Aggregated{" "}
+              <PriceName assetClass={props.feed.product.asset_type} />
+            </>
+          )
+        }
+        stat={
+          props.isLoading ? (
+            <Skeleton width={20} />
+          ) : (
+            <LivePrice
+              feedKey={props.feed.product.price_account}
+              cluster={Cluster.Pythnet}
+            />
+          )
+        }
+      />
+      <StatCard
+        header="Confidence"
+        stat={
+          props.isLoading ? (
+            <Skeleton width={20} />
+          ) : (
+            <LiveConfidence
+              feedKey={props.feed.product.price_account}
+              cluster={Cluster.Pythnet}
+            />
+          )
+        }
+        corner={
+          <Explain size="xs" title="Confidence">
+            <p>
+              <b>Confidence</b> is how far from the aggregate price Pyth
+              believes the true price might be. It reflects a combination of the
+              confidence of individual quoters and how well individual quoters
+              agree with each other.
+            </p>
+            <Button
+              size="xs"
+              variant="solid"
+              href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
+              target="_blank"
+            >
+              Learn more
+            </Button>
+          </Explain>
+        }
+      />
+      <StatCard
+        header={
+          props.isLoading ? (
+            <Skeleton width={30} />
+          ) : (
+            <>
+              1-Day <PriceName assetClass={props.feed.product.asset_type} />{" "}
+              Change
+            </>
+          )
+        }
+        stat={
+          props.isLoading ? (
+            <Skeleton width={20} />
+          ) : (
+            <YesterdaysPricesProvider
+              feeds={{ [props.feed.symbol]: props.feed.product.price_account }}
+            >
+              <PriceFeedChangePercent
+                feedKey={props.feed.product.price_account}
+              />
+            </YesterdaysPricesProvider>
+          )
+        }
+      />
+      <StatCard
+        header="Last Updated"
+        stat={
+          props.isLoading ? (
+            <Skeleton width={20} />
+          ) : (
+            <LiveLastUpdated
+              feedKey={props.feed.product.price_account}
+              cluster={Cluster.Pythnet}
+            />
+          )
+        }
+      />
+    </Cards>
+  </section>
+);

+ 0 - 66
apps/insights/src/components/PriceFeed/layout.module.scss

@@ -1,64 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
 .priceFeedLayout {
-  .header {
-    margin-bottom: theme.spacing(4);
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(4);
-
-    @include theme.max-width;
-
-    @include theme.breakpoint("sm") {
-      margin-bottom: theme.spacing(6);
-      gap: theme.spacing(6);
-    }
-
-    .headerRow {
-      display: flex;
-      flex-flow: column nowrap;
-      gap: theme.spacing(2);
-      justify-content: space-between;
-
-      @include theme.breakpoint("sm") {
-        flex-flow: row nowrap;
-        align-items: center;
-        gap: unset;
-      }
-    }
-
-    .rightGroup {
-      display: flex;
-      flex-flow: row nowrap;
-      align-items: stretch;
-      gap: theme.spacing(4);
-
-      & > * {
-        flex: 1 1 0;
-        width: 0;
-
-        @include theme.breakpoint("sm") {
-          flex: unset;
-          width: unset;
-        }
-      }
-    }
-
-    .priceFeedSelect {
-      display: none;
-
-      @include theme.breakpoint("sm") {
-        display: block;
-      }
-    }
-
-    .priceFeedTag {
-      @include theme.breakpoint("sm") {
-        display: none;
-      }
-    }
-  }
-
   .priceComponentsTabLabel {
     display: inline-flex;
     flex-flow: row nowrap;
@@ -72,11 +14,3 @@
     @include theme.max-width;
   }
 }
-
-.confidenceDescription {
-  margin: 0;
-
-  b {
-    font-weight: theme.font-weight("semibold");
-  }
-}

+ 29 - 197
apps/insights/src/components/PriceFeed/layout.tsx

@@ -1,206 +1,38 @@
-import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
-import { Badge } from "@pythnetwork/component-library/Badge";
-import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
-import { StatCard } from "@pythnetwork/component-library/StatCard";
-import { notFound } from "next/navigation";
 import type { ReactNode } from "react";
 
 import styles from "./layout.module.scss";
-import { PriceFeedSelect } from "./price-feed-select";
-import { ReferenceData } from "./reference-data";
-import { Cluster, getFeeds } from "../../services/pyth";
-import { Cards } from "../Cards";
-import { Explain } from "../Explain";
-import { FeedKey } from "../FeedKey";
-import {
-  LivePrice,
-  LiveConfidence,
-  LiveLastUpdated,
-  LiveValue,
-} from "../LivePrices";
-import {
-  YesterdaysPricesProvider,
-  PriceFeedChangePercent,
-} from "../PriceFeedChangePercent";
-import { PriceFeedTag } from "../PriceFeedTag";
-import { PriceName } from "../PriceName";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 
 type Props = {
+  header: ReactNode;
+  feedCountBadge: ReactNode;
   children: ReactNode;
-  params: Promise<{
-    slug: string;
-  }>;
 };
 
-export const PriceFeedLayout = async ({ children, params }: Props) => {
-  const [{ slug }, feeds] = await Promise.all([
-    params,
-    getFeeds(Cluster.Pythnet),
-  ]);
-  const symbol = decodeURIComponent(slug);
-  const feed = feeds.find((item) => item.symbol === symbol);
-
-  return feed ? (
-    <div className={styles.priceFeedLayout}>
-      <section className={styles.header}>
-        <div className={styles.headerRow}>
-          <Breadcrumbs
-            label="Breadcrumbs"
-            items={[
-              { href: "/", label: "Home" },
-              { href: "/price-feeds", label: "Price Feeds" },
-              { label: feed.product.display_symbol },
-            ]}
-          />
-          <div>
-            <Badge variant="neutral" style="outline" size="md">
-              {feed.product.asset_type.toUpperCase()}
-            </Badge>
-          </div>
-        </div>
-        <div className={styles.headerRow}>
-          <PriceFeedSelect className={styles.priceFeedSelect}>
-            <PriceFeedTag symbol={feed.symbol} />
-          </PriceFeedSelect>
-          <PriceFeedTag className={styles.priceFeedTag} symbol={feed.symbol} />
-          <div className={styles.rightGroup}>
-            <FeedKey
-              className={styles.feedKey ?? ""}
-              feedKey={feed.product.price_account}
-            />
-            <DrawerTrigger>
-              <Button variant="outline" size="sm" beforeIcon={ListDashes}>
-                Reference Data
-              </Button>
-              <Drawer fill title="Reference Data">
-                <ReferenceData
-                  feed={{
-                    symbol: feed.symbol,
-                    feedKey: feed.product.price_account,
-                    assetClass: feed.product.asset_type,
-                    base: feed.product.base,
-                    description: feed.product.description,
-                    country: feed.product.country,
-                    quoteCurrency: feed.product.quote_currency,
-                    tenor: feed.product.tenor,
-                    cmsSymbol: feed.product.cms_symbol,
-                    cqsSymbol: feed.product.cqs_symbol,
-                    nasdaqSymbol: feed.product.nasdaq_symbol,
-                    genericSymbol: feed.product.generic_symbol,
-                    weeklySchedule: feed.product.weekly_schedule,
-                    schedule: feed.product.schedule,
-                    contractId: feed.product.contract_id,
-                    displaySymbol: feed.product.display_symbol,
-                    exponent: feed.price.exponent,
-                    numComponentPrices: feed.price.numComponentPrices,
-                    numQuoters: feed.price.numQuoters,
-                    minPublishers: feed.price.minPublishers,
-                    lastSlot: feed.price.lastSlot,
-                    validSlot: feed.price.validSlot,
-                  }}
-                />
-              </Drawer>
-            </DrawerTrigger>
-          </div>
-        </div>
-        <Cards>
-          <StatCard
-            variant="primary"
-            header={
-              <>
-                Aggregated <PriceName assetClass={feed.product.asset_type} />
-              </>
-            }
-            stat={
-              <LivePrice
-                feedKey={feed.product.price_account}
-                cluster={Cluster.Pythnet}
-              />
-            }
-          />
-          <StatCard
-            header="Confidence"
-            stat={
-              <LiveConfidence
-                feedKey={feed.product.price_account}
-                cluster={Cluster.Pythnet}
-              />
-            }
-            corner={
-              <Explain size="xs" title="Confidence">
-                <p>
-                  <b>Confidence</b> is how far from the aggregate price Pyth
-                  believes the true price might be. It reflects a combination of
-                  the confidence of individual quoters and how well individual
-                  quoters agree with each other.
-                </p>
-                <Button
-                  size="xs"
-                  variant="solid"
-                  href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
-                  target="_blank"
-                >
-                  Learn more
-                </Button>
-              </Explain>
-            }
-          />
-          <StatCard
-            header={
-              <>
-                1-Day <PriceName assetClass={feed.product.asset_type} /> Change
-              </>
-            }
-            stat={
-              <YesterdaysPricesProvider
-                feeds={{ [feed.symbol]: feed.product.price_account }}
-              >
-                <PriceFeedChangePercent feedKey={feed.product.price_account} />
-              </YesterdaysPricesProvider>
-            }
-          />
-          <StatCard
-            header="Last Updated"
-            stat={
-              <LiveLastUpdated
-                feedKey={feed.product.price_account}
-                cluster={Cluster.Pythnet}
-              />
-            }
-          />
-        </Cards>
-      </section>
-      <TabRoot>
-        <Tabs
-          label="Price Feed Navigation"
-          prefix={`/price-feeds/${slug}`}
-          items={[
-            { segment: undefined, children: "Chart" },
-            {
-              segment: "publishers",
-              children: (
-                <div className={styles.priceComponentsTabLabel}>
-                  <span>Publishers</span>
-                  <Badge size="xs" style="filled" variant="neutral">
-                    <LiveValue
-                      feedKey={feed.product.price_account}
-                      field="numComponentPrices"
-                      defaultValue={feed.price.numComponentPrices}
-                      cluster={Cluster.Pythnet}
-                    />
-                  </Badge>
-                </div>
-              ),
-            },
-          ]}
-        />
-        <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
-      </TabRoot>
-    </div>
-  ) : (
-    notFound()
-  );
-};
+export const PriceFeedLayout = ({
+  children,
+  feedCountBadge,
+  header,
+}: Props) => (
+  <div className={styles.priceFeedLayout}>
+    {header}
+    <TabRoot>
+      <Tabs
+        label="Price Feed Navigation"
+        items={[
+          { id: "(main)", segment: undefined, children: "Chart" },
+          {
+            segment: "publishers",
+            children: (
+              <div className={styles.priceComponentsTabLabel}>
+                <span>Publishers</span>
+                {feedCountBadge}
+              </div>
+            ),
+          },
+        ]}
+      />
+      <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
+    </TabRoot>
+  </div>
+);

+ 124 - 62
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -21,92 +21,154 @@ import { useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./price-feed-select.module.scss";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import { Cluster } from "../../services/pyth";
-import { AssetClassTag } from "../AssetClassTag";
+import { AssetClassBadge } from "../AssetClassBadge";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   className: string | undefined;
   children: ReactNode;
+} & (
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      feeds: {
+        symbol: string;
+        displaySymbol: string;
+        assetClass: string;
+        key: string;
+        description: string;
+        icon: ReactNode;
+      }[];
+    }
+);
+
+export const PriceFeedSelect = (props: Props) =>
+  props.isLoading ? (
+    <PriceFeedSelectImpl {...props} />
+  ) : (
+    <ResolvedPriceFeedSelect {...props} />
+  );
+
+type ResolvedPriceFeedSelect = {
+  className: string | undefined;
+  children: ReactNode;
+  feeds: {
+    symbol: string;
+    displaySymbol: string;
+    assetClass: string;
+    key: string;
+    description: string;
+    icon: ReactNode;
+  }[];
 };
 
-export const PriceFeedSelect = ({ children, className }: Props) => {
-  const feeds = usePriceFeeds();
+const ResolvedPriceFeedSelect = ({
+  feeds,
+  ...props
+}: ResolvedPriceFeedSelect) => {
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
   const [search, setSearch] = useState("");
   const filteredFeeds = useMemo(
     () =>
       search === ""
-        ? // This is inefficient but Safari doesn't support `Iterator.filter`, see
-          // https://bugs.webkit.org/show_bug.cgi?id=248650
-          [...feeds.entries()]
-        : [...feeds.entries()].filter(
-            ([, { displaySymbol, assetClass, key }]) =>
+        ? feeds
+        : feeds.filter(
+            ({ displaySymbol, assetClass, key }) =>
               filter.contains(displaySymbol, search) ||
               filter.contains(assetClass, search) ||
-              filter.contains(key[Cluster.Pythnet], search),
+              filter.contains(key, search),
           ),
     [feeds, search, filter],
   );
   const sortedFeeds = useMemo(
     () =>
-      // eslint-disable-next-line unicorn/no-useless-spread
-      [
-        ...filteredFeeds.map(([symbol, { displaySymbol }]) => ({
-          id: symbol,
-          displaySymbol,
-        })),
-      ].toSorted((a, b) => collator.compare(a.displaySymbol, b.displaySymbol)),
+      filteredFeeds.toSorted((a, b) =>
+        collator.compare(a.displaySymbol, b.displaySymbol),
+      ),
     [filteredFeeds, collator],
   );
+
   return (
-    <Select
-      aria-label="Select a Price Feed"
-      className={clsx(className, styles.priceFeedSelect)}
-    >
-      <Button className={styles.trigger ?? ""}>
-        {children}
-        <DropdownCaretDown className={styles.caret} />
-      </Button>
-      <Popover placement="bottom start" className={styles.popover ?? ""}>
-        <Dialog aria-label="Price Feeds" className={styles.dialog ?? ""}>
-          <SearchField
-            value={search}
-            onChange={setSearch}
-            className={styles.searchField ?? ""}
-            // eslint-disable-next-line jsx-a11y/no-autofocus
-            autoFocus
-            aria-label="Search"
-          >
-            <Input
-              className={styles.searchInput ?? ""}
-              placeholder="Symbol, asset class, or key"
-            />
-          </SearchField>
-          <Virtualizer layout={new ListLayout()}>
-            <ListBox
-              items={sortedFeeds}
-              className={styles.listbox ?? ""}
+    <PriceFeedSelectImpl
+      menu={
+        <Popover placement="bottom start" className={styles.popover ?? ""}>
+          <Dialog aria-label="Price Feeds" className={styles.dialog ?? ""}>
+            <SearchField
+              value={search}
+              onChange={setSearch}
+              className={styles.searchField ?? ""}
               // eslint-disable-next-line jsx-a11y/no-autofocus
-              autoFocus={false}
+              autoFocus
+              aria-label="Search"
             >
-              {({ id, displaySymbol }) => (
-                <ListBoxItem
-                  textValue={displaySymbol}
-                  className={styles.priceFeed ?? ""}
-                  href={`/price-feeds/${encodeURIComponent(id)}`}
-                  data-is-first={id === sortedFeeds[0]?.id ? "" : undefined}
-                >
-                  <PriceFeedTag compact symbol={id} />
-                  <AssetClassTag symbol={id} />
-                </ListBoxItem>
-              )}
-            </ListBox>
-          </Virtualizer>
-        </Dialog>
-      </Popover>
-    </Select>
+              <Input
+                className={styles.searchInput ?? ""}
+                placeholder="Symbol, asset class, or key"
+              />
+            </SearchField>
+            <Virtualizer layout={new ListLayout()}>
+              <ListBox
+                items={sortedFeeds}
+                className={styles.listbox ?? ""}
+                // eslint-disable-next-line jsx-a11y/no-autofocus
+                autoFocus={false}
+              >
+                {({ symbol, displaySymbol, description, icon, assetClass }) => (
+                  <ListBoxItem
+                    textValue={displaySymbol}
+                    className={styles.priceFeed ?? ""}
+                    href={`/price-feeds/${encodeURIComponent(symbol)}`}
+                    data-is-first={
+                      symbol === sortedFeeds[0]?.symbol ? "" : undefined
+                    }
+                    prefetch={false}
+                  >
+                    <PriceFeedTag
+                      displaySymbol={displaySymbol}
+                      description={description}
+                      icon={icon}
+                    />
+                    <AssetClassBadge>{assetClass}</AssetClassBadge>
+                  </ListBoxItem>
+                )}
+              </ListBox>
+            </Virtualizer>
+          </Dialog>
+        </Popover>
+      }
+      {...props}
+    />
   );
 };
+
+type PriceFeedSelectImplProps = {
+  className: string | undefined;
+  children: ReactNode;
+} & (
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      menu: ReactNode;
+    }
+);
+
+const PriceFeedSelectImpl = ({
+  children,
+  className,
+  ...props
+}: PriceFeedSelectImplProps) => (
+  <Select
+    aria-label="Select a Price Feed"
+    className={clsx(className, styles.priceFeedSelect)}
+  >
+    <Button
+      className={styles.trigger ?? ""}
+      isPending={props.isLoading ?? false}
+    >
+      {children}
+      <DropdownCaretDown className={styles.caret} />
+    </Button>
+    {!props.isLoading && props.menu}
+  </Select>
+);

+ 82 - 126
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -2,158 +2,114 @@
 
 import { useLogger } from "@pythnetwork/app-logger";
 import { Switch } from "@pythnetwork/component-library/Switch";
-import { useQueryState, parseAsString, parseAsBoolean } from "nuqs";
-import type { ComponentProps } from "react";
+import { useQueryState, parseAsBoolean } from "nuqs";
 import { Suspense, useCallback, useMemo } from "react";
 
-import { Cluster, ClusterToName } from "../../services/pyth";
-import { PriceComponentDrawer } from "../PriceComponentDrawer";
-import {
-  PriceComponentsCardContents,
-  ResolvedPriceComponentsCard,
-} from "../PriceComponentsCard";
+import { Cluster } from "../../services/pyth";
+import type { PriceComponent } from "../PriceComponentsCard";
+import { PriceComponentsCard } from "../PriceComponentsCard";
+import { PublisherTag } from "../PublisherTag";
 
-type Publisher = ComponentProps<
-  typeof ResolvedPriceComponentsCard
->["priceComponents"][number] &
-  Pick<ComponentProps<typeof PriceComponentDrawer>, "rank"> & {
-    firstEvaluation?: Date | undefined;
-  };
+type PublishersCardProps =
+  | { isLoading: true }
+  | (ResolvedPublishersCardProps & {
+      isLoading?: false | undefined;
+    });
 
-type Props = Omit<
-  ComponentProps<typeof ResolvedPriceComponentsCard>,
-  "onPriceComponentAction" | "priceComponents"
-> & {
-  priceComponents: Publisher[];
+export const PublishersCard = (props: PublishersCardProps) =>
+  props.isLoading ? (
+    <PublishersCardImpl {...props} />
+  ) : (
+    <Suspense>
+      <ResolvedPublishersCard {...props} />
+    </Suspense>
+  );
+
+type ResolvedPublishersCardProps = {
   symbol: string;
   displaySymbol: string;
   assetClass: string;
+  publishers: Omit<PriceComponent, "symbol" | "displaySymbol" | "assetClass">[];
+  metricsTime?: Date | undefined;
 };
 
-export const PublishersCard = ({
-  priceComponents,
-  symbol,
-  displaySymbol,
-  ...props
-}: Props) => (
-  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPublishersCard
-      priceComponents={priceComponents}
-      symbol={symbol}
-      displaySymbol={displaySymbol}
-      {...props}
-    />
-  </Suspense>
-);
-
 const ResolvedPublishersCard = ({
-  priceComponents,
-  symbol,
-  displaySymbol,
-  assetClass,
+  publishers,
   ...props
-}: Props) => {
+}: ResolvedPublishersCardProps) => {
   const logger = useLogger();
-  const { handleClose, selectedPublisher, updateSelectedPublisherKey } =
-    usePublisherDrawer(priceComponents);
-  const onPriceComponentAction = useCallback(
-    ({ publisherKey, cluster }: Publisher) => {
-      updateSelectedPublisherKey(
-        [ClusterToName[cluster], publisherKey].join(":"),
-      );
-    },
-    [updateSelectedPublisherKey],
-  );
+
   const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
     "includeTestFeeds",
     parseAsBoolean.withDefault(false),
   );
-  const componentsFilteredByCluster = useMemo(
-    () =>
-      includeTestFeeds
-        ? priceComponents
-        : priceComponents.filter(
-            (component) => component.cluster === Cluster.Pythnet,
-          ),
-    [includeTestFeeds, priceComponents],
-  );
+
   const updateIncludeTestFeeds = useCallback(
     (newValue: boolean) => {
       setIncludeTestFeeds(newValue).catch((error: unknown) => {
-        logger.error(
-          "Failed to update include test components query param",
-          error,
-        );
+        logger.error("Failed to update show quality", error);
       });
     },
     [setIncludeTestFeeds, logger],
   );
 
+  const publishersFilteredByCluster = useMemo(
+    () =>
+      includeTestFeeds
+        ? publishers
+        : publishers.filter(
+            (component) => component.cluster === Cluster.Pythnet,
+          ),
+    [includeTestFeeds, publishers],
+  );
+
   return (
-    <>
-      <ResolvedPriceComponentsCard
-        onPriceComponentAction={onPriceComponentAction}
-        priceComponents={componentsFilteredByCluster}
-        assetClass={assetClass}
-        toolbarExtra={
-          <Switch
-            isSelected={includeTestFeeds}
-            onChange={updateIncludeTestFeeds}
-          >
-            Include test publishers
-          </Switch>
-        }
-        {...props}
-      />
-      {selectedPublisher && (
-        <PriceComponentDrawer
-          publisherKey={selectedPublisher.publisherKey}
-          onClose={handleClose}
-          symbol={symbol}
-          displaySymbol={displaySymbol}
-          feedKey={selectedPublisher.feedKey}
-          rank={selectedPublisher.rank}
-          score={selectedPublisher.score}
-          status={selectedPublisher.status}
-          title={selectedPublisher.name}
-          cluster={selectedPublisher.cluster}
-          firstEvaluation={selectedPublisher.firstEvaluation ?? new Date()}
-          navigateHref={`/publishers/${ClusterToName[selectedPublisher.cluster]}/${selectedPublisher.publisherKey}`}
-          assetClass={assetClass}
-          identifiesPublisher
-        />
-      )}
-    </>
+    <PublishersCardImpl
+      includeTestFeeds={includeTestFeeds}
+      updateIncludeTestFeeds={updateIncludeTestFeeds}
+      publishers={publishersFilteredByCluster}
+      {...props}
+    />
   );
 };
 
-const usePublisherDrawer = (publishers: Publisher[]) => {
-  const logger = useLogger();
-  const [selectedPublisherKey, setSelectedPublisher] = useQueryState(
-    "publisher",
-    parseAsString.withDefault("").withOptions({
-      history: "push",
-    }),
-  );
-  const updateSelectedPublisherKey = useCallback(
-    (newPublisherKey: string) => {
-      setSelectedPublisher(newPublisherKey).catch((error: unknown) => {
-        logger.error("Failed to update selected publisher", error);
-      });
-    },
-    [setSelectedPublisher, logger],
-  );
-  const selectedPublisher = useMemo(() => {
-    const [cluster, publisherKey] = selectedPublisherKey.split(":");
-    return publishers.find(
-      (publisher) =>
-        publisher.publisherKey === publisherKey &&
-        ClusterToName[publisher.cluster] === cluster,
-    );
-  }, [selectedPublisherKey, publishers]);
-  const handleClose = useCallback(() => {
-    updateSelectedPublisherKey("");
-  }, [updateSelectedPublisherKey]);
+type PublishersCardImplProps =
+  | { isLoading: true }
+  | (ResolvedPublishersCardProps & {
+      isLoading?: false | undefined;
+      includeTestFeeds: boolean;
+      updateIncludeTestFeeds: (newValue: boolean) => void;
+    });
 
-  return { selectedPublisher, handleClose, updateSelectedPublisherKey };
-};
+const PublishersCardImpl = (props: PublishersCardImplProps) => (
+  <PriceComponentsCard
+    label="Publishers"
+    searchPlaceholder="Publisher key or name"
+    nameLoadingSkeleton={<PublisherTag isLoading />}
+    identifiesPublisher
+    toolbarExtra={
+      <Switch
+        {...(props.isLoading
+          ? { isPending: true }
+          : {
+              isSelected: props.includeTestFeeds,
+              onChange: props.updateIncludeTestFeeds,
+            })}
+      >
+        Include test publishers
+      </Switch>
+    }
+    {...(props.isLoading
+      ? { isLoading: true }
+      : {
+          assetClass: props.assetClass,
+          metricsTime: props.metricsTime,
+          priceComponents: props.publishers.map((feed) => ({
+            ...feed,
+            symbol: props.symbol,
+            displaySymbol: props.displaySymbol,
+            assetClass: props.assetClass,
+          })),
+        })}
+  />
+);

+ 4 - 5
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -1,7 +1,6 @@
 import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
 
-import { PublishersCard } from "./publishers-card";
 import { getRankingsBySymbol } from "../../services/clickhouse";
 import {
   Cluster,
@@ -12,6 +11,7 @@ import {
 import { getStatus } from "../../status";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherTag } from "../PublisherTag";
+import { PublishersCard } from "./publishers-card";
 
 type Props = {
   params: Promise<{
@@ -46,14 +46,11 @@ export const Publishers = async ({ params }: Props) => {
     notFound()
   ) : (
     <PublishersCard
-      label="Publishers"
-      searchPlaceholder="Publisher key or name"
       metricsTime={metricsTime}
-      nameLoadingSkeleton={<PublisherTag isLoading />}
       symbol={symbol}
       displaySymbol={feed.product.display_symbol}
       assetClass={feed.product.asset_type}
-      priceComponents={publishers.map(
+      publishers={publishers.map(
         ({ ranking, publisher, status, cluster, knownPublisher }) => ({
           id: `${publisher}-${ClusterToName[cluster]}`,
           feedKey:
@@ -86,6 +83,8 @@ export const Publishers = async ({ params }: Props) => {
   );
 };
 
+export const PublishersLoading = () => <PublishersCard isLoading />;
+
 const getPublishers = async (cluster: Cluster, symbol: string) => {
   const [publishers, rankings] = await Promise.all([
     getPublishersForFeed(cluster, symbol),

+ 2 - 2
apps/insights/src/components/PriceFeed/reference-data.tsx

@@ -6,7 +6,7 @@ import { useCollator } from "react-aria";
 
 import styles from "./reference-data.module.scss";
 import { Cluster } from "../../services/pyth";
-import { AssetClassTag } from "../AssetClassTag";
+import { AssetClassBadge } from "../AssetClassBadge";
 import { LiveValue } from "../LivePrices";
 
 type Props = {
@@ -43,7 +43,7 @@ export const ReferenceData = ({ feed }: Props) => {
     () =>
       [
         ...Object.entries({
-          "Asset Type": <AssetClassTag symbol={feed.symbol} />,
+          "Asset Type": <AssetClassBadge>{feed.assetClass}</AssetClassBadge>,
           Base: feed.base,
           Description: feed.description,
           Symbol: feed.symbol,

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

@@ -21,7 +21,7 @@ type Props = Omit<SVGProps, keyof OwnProps | "width" | "height" | "viewBox"> &
 
 export const PriceFeedIcon = ({ assetClass, symbol, ...props }: Props) => {
   if (assetClass === "Crypto") {
-    const firstPart = symbol.split("/")[0];
+    const firstPart = symbol.split(".")[1]?.split("/")[0];
     const Icon = firstPart ? (icons as SVGRecord)[firstPart] : undefined;
     return Icon ? (
       <Icon width="100%" height="100%" viewBox="0 0 32 32" {...props} />

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

@@ -57,13 +57,6 @@
     }
   }
 
-  &[data-compact] {
-    .icon {
-      width: theme.spacing(6);
-      height: theme.spacing(6);
-    }
-  }
-
   &[data-loading] {
     .icon {
       border-radius: theme.border-radius("full");

+ 44 - 91
apps/insights/src/components/PriceFeedTag/index.tsx

@@ -1,110 +1,63 @@
-"use client";
-
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
 import { Fragment } from "react";
 
 import styles from "./index.module.scss";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
 import { omitKeys } from "../../omit-keys";
 
-type OwnProps = { compact?: boolean | undefined } & (
-  | { isLoading: true }
-  | {
-      isLoading?: false;
-      symbol: string;
-    }
-);
-
-type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
-
-export const PriceFeedTag = (props: Props) => {
-  return props.isLoading ? (
-    <PriceFeedTagImpl {...props} />
-  ) : (
-    <LoadedPriceFeedTag {...props} />
-  );
-};
-
-const LoadedPriceFeedTag = ({
-  symbol,
-  ...props
-}: Props & { isLoading?: false }) => {
-  const feed = usePriceFeeds().get(symbol);
-  if (feed) {
-    const [firstPart, ...rest] = feed.displaySymbol.split("/");
-    return (
-      <PriceFeedTagImpl
-        description={feed.description}
-        feedName={[firstPart ?? "", ...rest]}
-        icon={feed.icon}
-        {...props}
-      />
-    );
-  } else {
-    throw new NoSuchFeedError(symbol);
-  }
-};
-
-type OwnImplProps = { compact?: boolean | undefined } & (
+type OwnProps =
   | { isLoading: true }
   | {
       isLoading?: false;
-      feedName: [string, ...string[]];
-      icon: ReactNode;
+      icon: ReactNode | undefined;
+      displaySymbol: string;
       description: string;
-    }
-);
+    };
 
-type ImplProps = Omit<ComponentProps<"div">, keyof OwnImplProps> & OwnImplProps;
+type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
 
-const PriceFeedTagImpl = ({ className, compact, ...props }: ImplProps) => {
-  return (
-    <div
-      className={clsx(styles.priceFeedTag, className)}
-      data-compact={compact ? "" : undefined}
-      data-loading={props.isLoading ? "" : undefined}
-      {...omitKeys(props, ["feedName", "icon", "description"])}
-    >
-      {props.isLoading ? (
-        <Skeleton fill className={styles.icon} />
-      ) : (
-        <div className={styles.icon}>{props.icon}</div>
-      )}
-      <div className={styles.nameAndDescription}>
-        <div className={styles.name}>
-          {props.isLoading ? (
-            <Skeleton width={30} />
-          ) : (
-            <>
-              <span className={styles.firstPart}>{props.feedName[0]}</span>
-              {props.feedName.slice(1).map((part, i) => (
-                <Fragment key={i}>
-                  <span className={styles.divider}>/</span>
-                  <span className={styles.part}>{part}</span>
-                </Fragment>
-              ))}
-            </>
-          )}
-        </div>
-        {!compact && (
-          <div className={styles.description}>
-            {props.isLoading ? (
-              <Skeleton width={50} />
-            ) : (
-              props.description.split("/")[0]
-            )}
-          </div>
+export const PriceFeedTag = ({ className, ...props }: Props) => (
+  <div
+    className={clsx(styles.priceFeedTag, className)}
+    data-loading={props.isLoading ? "" : undefined}
+    {...omitKeys(props, ["displaySymbol", "description", "isLoading"])}
+  >
+    {props.isLoading ? (
+      <Skeleton fill className={styles.icon} />
+    ) : (
+      <div className={styles.icon}>{props.icon}</div>
+    )}
+    <div className={styles.nameAndDescription}>
+      <div className={styles.name}>
+        {props.isLoading ? (
+          <Skeleton width={30} />
+        ) : (
+          <FeedName displaySymbol={props.displaySymbol} />
+        )}
+      </div>
+      <div className={styles.description}>
+        {props.isLoading ? (
+          <Skeleton width={50} />
+        ) : (
+          props.description.split("/")[0]
         )}
       </div>
     </div>
+  </div>
+);
+
+const FeedName = ({ displaySymbol }: { displaySymbol: string }) => {
+  const [firstPart, ...rest] = displaySymbol.split("/");
+  return (
+    <>
+      <span className={styles.firstPart}>{firstPart}</span>
+      {rest.map((part, i) => (
+        <Fragment key={i}>
+          <span className={styles.divider}>/</span>
+          <span className={styles.part}>{part}</span>
+        </Fragment>
+      ))}
+    </>
   );
 };
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

+ 10 - 55
apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx → apps/insights/src/components/PriceFeeds/asset-class-table.tsx

@@ -2,12 +2,8 @@
 
 import { useLogger } from "@pythnetwork/app-logger";
 import { Badge } from "@pythnetwork/component-library/Badge";
-import {
-  CLOSE_DURATION_IN_MS,
-  Drawer,
-  DrawerTrigger,
-} from "@pythnetwork/component-library/Drawer";
 import { Table } from "@pythnetwork/component-library/Table";
+import { useDrawer } from "@pythnetwork/component-library/useDrawer";
 import { usePathname } from "next/navigation";
 import {
   parseAsString,
@@ -15,56 +11,15 @@ import {
   useQueryStates,
   createSerializer,
 } from "nuqs";
-import type { ReactNode } from "react";
 import { useMemo } from "react";
 import { useCollator } from "react-aria";
 
 type Props = {
   numFeedsByAssetClass: Record<string, number>;
-  children: ReactNode;
-};
-
-export const AssetClassesDrawer = ({
-  numFeedsByAssetClass,
-  children,
-}: Props) => {
-  const numAssetClasses = useMemo(
-    () => Object.keys(numFeedsByAssetClass).length,
-    [numFeedsByAssetClass],
-  );
-
-  return (
-    <DrawerTrigger>
-      {children}
-      <Drawer
-        fill
-        title={
-          <>
-            <span>Asset Classes</span>
-            <Badge>{numAssetClasses}</Badge>
-          </>
-        }
-      >
-        {({ state }) => (
-          <AssetClassTable
-            numFeedsByAssetClass={numFeedsByAssetClass}
-            state={state}
-          />
-        )}
-      </Drawer>
-    </DrawerTrigger>
-  );
-};
-
-type AssetClassTableProps = {
-  numFeedsByAssetClass: Record<string, number>;
-  state: { close: () => void };
 };
 
-const AssetClassTable = ({
-  numFeedsByAssetClass,
-  state,
-}: AssetClassTableProps) => {
+export const AssetClassTable = ({ numFeedsByAssetClass }: Props) => {
+  const drawer = useDrawer();
   const logger = useLogger();
   const collator = useCollator();
   const pathname = usePathname();
@@ -85,12 +40,12 @@ const AssetClassTable = ({
             id: assetClass,
             href: `${pathname}${serialize(newQuery)}`,
             onAction: () => {
-              state.close();
-              setTimeout(() => {
-                setQuery(newQuery).catch((error: unknown) => {
-                  logger.error("Failed to update query", error);
-                });
-              }, CLOSE_DURATION_IN_MS);
+              drawer.close().catch((error: unknown) => {
+                logger.error(error);
+              });
+              setQuery(newQuery).catch((error: unknown) => {
+                logger.error("Failed to update query", error);
+              });
             },
             data: {
               assetClass,
@@ -101,7 +56,7 @@ const AssetClassTable = ({
     [
       numFeedsByAssetClass,
       collator,
-      state,
+      drawer,
       pathname,
       setQuery,
       serialize,

+ 27 - 36
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -3,41 +3,30 @@
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Table } from "@pythnetwork/component-library/Table";
+import type { ReactNode } from "react";
 import { useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./coming-soon-list.module.scss";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import { AssetClassTag } from "../AssetClassTag";
+import { AssetClassBadge } from "../AssetClassBadge";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
-  comingSoonSymbols: string[];
+  comingSoonFeeds: {
+    symbol: string;
+    assetClass: string;
+    displaySymbol: string;
+    description: string;
+    icon: ReactNode;
+  }[];
 };
 
-export const ComingSoonList = ({ comingSoonSymbols }: Props) => {
+export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
   const [search, setSearch] = useState("");
   const [assetClass, setAssetClass] = useState("");
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const feeds = usePriceFeeds();
-  const comingSoonFeeds = useMemo(
-    () =>
-      comingSoonSymbols.map((symbol) => {
-        const feed = feeds.get(symbol);
-        if (feed) {
-          return {
-            symbol,
-            assetClass: feed.assetClass,
-            displaySymbol: feed.displaySymbol,
-          };
-        } else {
-          throw new NoSuchFeedError(symbol);
-        }
-      }),
-    [feeds, comingSoonSymbols],
-  );
   const assetClasses = useMemo(
     () =>
       [
@@ -70,16 +59,25 @@ export const ComingSoonList = ({ comingSoonSymbols }: Props) => {
   );
   const rows = useMemo(
     () =>
-      filteredFeeds.map(({ symbol }) => ({
-        id: symbol,
-        href: `/price-feeds/${encodeURIComponent(symbol)}`,
-        data: {
-          priceFeedName: <PriceFeedTag compact symbol={symbol} />,
-          assetClass: <AssetClassTag symbol={symbol} />,
-        },
-      })),
+      filteredFeeds.map(
+        ({ symbol, assetClass, description, displaySymbol, icon }) => ({
+          id: symbol,
+          href: `/price-feeds/${encodeURIComponent(symbol)}`,
+          data: {
+            priceFeedName: (
+              <PriceFeedTag
+                description={description}
+                displaySymbol={displaySymbol}
+                icon={icon}
+              />
+            ),
+            assetClass: <AssetClassBadge>{assetClass}</AssetClassBadge>,
+          },
+        }),
+      ),
     [filteredFeeds],
   );
+
   return (
     <div className={styles.comingSoonList}>
       <div className={styles.searchBar}>
@@ -139,10 +137,3 @@ export const ComingSoonList = ({ comingSoonSymbols }: Props) => {
     </div>
   );
 };
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

+ 80 - 26
apps/insights/src/components/PriceFeeds/index.tsx

@@ -7,7 +7,6 @@ import { Badge } from "@pythnetwork/component-library/Badge";
 import { Button } from "@pythnetwork/component-library/Button";
 import type { Props as CardProps } from "@pythnetwork/component-library/Card";
 import { Card } from "@pythnetwork/component-library/Card";
-import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { TabList } from "@pythnetwork/component-library/TabList";
 import {
@@ -16,7 +15,7 @@ import {
 } from "@pythnetwork/component-library/unstyled/Tabs";
 import type { ElementType } from "react";
 
-import { AssetClassesDrawer } from "./asset-classes-drawer";
+import { AssetClassTable } from "./asset-class-table";
 import { ComingSoonList } from "./coming-soon-list";
 import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
@@ -29,6 +28,7 @@ import {
   YesterdaysPricesProvider,
   PriceFeedChangePercent,
 } from "../PriceFeedChangePercent";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 const PRICE_FEEDS_ANCHOR = "priceFeeds";
@@ -50,6 +50,7 @@ export const PriceFeeds = async () => {
     priceFeeds.activeFeeds,
     priceFeedsStaticConfig.featuredFeeds,
   );
+  const numAssetClasses = Object.keys(numFeedsByAssetClass).length;
 
   return (
     <div className={styles.priceFeeds}>
@@ -73,13 +74,23 @@ export const PriceFeeds = async () => {
           target="_blank"
           corner={<ArrowSquareOut weight="fill" />}
         />
-        <AssetClassesDrawer numFeedsByAssetClass={numFeedsByAssetClass}>
-          <StatCard
-            header="Asset Classes"
-            stat={Object.keys(numFeedsByAssetClass).length}
-            corner={<ArrowsOutSimple />}
-          />
-        </AssetClassesDrawer>
+        <StatCard
+          header="Asset Classes"
+          stat={Object.keys(numFeedsByAssetClass).length}
+          corner={<ArrowsOutSimple />}
+          drawer={{
+            fill: true,
+            title: (
+              <>
+                <span>Asset Classes</span>
+                <Badge>{numAssetClasses}</Badge>
+              </>
+            ),
+            contents: (
+              <AssetClassTable numFeedsByAssetClass={numFeedsByAssetClass} />
+            ),
+          }}
+        />
       </Cards>
       <section className={styles.bigScreenBody}>
         <FeaturedFeeds
@@ -90,9 +101,19 @@ export const PriceFeeds = async () => {
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
           priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
+            key: feed.product.price_account,
             symbol: feed.symbol,
             exponent: feed.price.exponent,
             numQuoters: feed.price.numQuoters,
+            assetClass: feed.product.asset_type,
+            description: feed.product.description,
+            displaySymbol: feed.product.display_symbol,
+            icon: (
+              <PriceFeedIcon
+                assetClass={feed.product.asset_type}
+                symbol={feed.symbol}
+              />
+            ),
           }))}
         />
       </section>
@@ -108,9 +129,19 @@ export const PriceFeeds = async () => {
           <PriceFeedsCard
             id={PRICE_FEEDS_ANCHOR}
             priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
+              key: feed.product.price_account,
               symbol: feed.symbol,
               exponent: feed.price.exponent,
               numQuoters: feed.price.numQuoters,
+              assetClass: feed.product.asset_type,
+              description: feed.product.description,
+              displaySymbol: feed.product.display_symbol,
+              icon: (
+                <PriceFeedIcon
+                  assetClass={feed.product.asset_type}
+                  symbol={feed.symbol}
+                />
+              ),
             }))}
           />
         </UnstyledTabPanel>
@@ -129,7 +160,7 @@ export const PriceFeeds = async () => {
 type FeaturedFeedsProps = {
   featuredFeeds: FeaturedFeed[];
   featuredComingSoon: FeaturedFeed[];
-  allComingSoon: { symbol: string }[];
+  allComingSoon: FeaturedFeed[];
 };
 
 const FeaturedFeeds = ({
@@ -159,25 +190,38 @@ const FeaturedFeeds = ({
       feeds={featuredComingSoon}
       toolbarAlwaysOnTop
       toolbar={
-        <DrawerTrigger>
-          <Button size="sm" variant="outline">
-            Show all
-          </Button>
-          <Drawer
-            fill
-            className={styles.comingSoonCard ?? ""}
-            title={
+        <Button
+          size="sm"
+          variant="outline"
+          drawer={{
+            fill: true,
+            className: styles.comingSoonCard ?? "",
+            title: (
               <>
                 <span>Coming Soon</span>
                 <Badge>{allComingSoon.length}</Badge>
               </>
-            }
-          >
-            <ComingSoonList
-              comingSoonSymbols={allComingSoon.map(({ symbol }) => symbol)}
-            />
-          </Drawer>
-        </DrawerTrigger>
+            ),
+            contents: (
+              <ComingSoonList
+                comingSoonFeeds={allComingSoon.map((feed) => ({
+                  assetClass: feed.product.asset_type,
+                  description: feed.product.description,
+                  displaySymbol: feed.product.display_symbol,
+                  symbol: feed.symbol,
+                  icon: (
+                    <PriceFeedIcon
+                      assetClass={feed.product.asset_type}
+                      symbol={feed.symbol}
+                    />
+                  ),
+                }))}
+              />
+            ),
+          }}
+        >
+          Show all
+        </Button>
       }
     />
   </>
@@ -197,6 +241,7 @@ type FeaturedFeed = {
     display_symbol: string;
     price_account: string;
     description: string;
+    asset_type: string;
   };
 };
 
@@ -214,7 +259,16 @@ const FeaturedFeedsCard = <T extends ElementType>({
           href={`/price-feeds/${encodeURIComponent(feed.symbol)}`}
         >
           <div className={styles.feedCardContents}>
-            <PriceFeedTag symbol={feed.symbol} />
+            <PriceFeedTag
+              displaySymbol={feed.product.display_symbol}
+              description={feed.product.description}
+              icon={
+                <PriceFeedIcon
+                  assetClass={feed.product.asset_type}
+                  symbol={feed.symbol}
+                />
+              }
+            />
             {showPrices && (
               <div className={styles.prices}>
                 <LivePrice

+ 39 - 48
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -13,14 +13,14 @@ import type {
 } from "@pythnetwork/component-library/Table";
 import { Table } from "@pythnetwork/component-library/Table";
 import { useQueryState, parseAsString } from "nuqs";
+import type { ReactNode } from "react";
 import { Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import styles from "./price-feeds-card.module.scss";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
-import { AssetClassTag } from "../AssetClassTag";
+import { AssetClassBadge } from "../AssetClassBadge";
 import { EntityList } from "../EntityList";
 import { FeedKey } from "../FeedKey";
 import {
@@ -36,13 +36,16 @@ import rootStyles from "../Root/index.module.scss";
 
 type Props = {
   id: string;
-  priceFeeds: PriceFeed[];
-};
-
-type PriceFeed = {
-  symbol: string;
-  exponent: number;
-  numQuoters: number;
+  priceFeeds: {
+    symbol: string;
+    exponent: number;
+    numQuoters: number;
+    assetClass: string;
+    displaySymbol: string;
+    key: string;
+    description: string;
+    icon: ReactNode;
+  }[];
 };
 
 export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
@@ -59,32 +62,12 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
     "assetClass",
     parseAsString.withDefault(""),
   );
-  const feeds = usePriceFeeds();
-  const priceFeedsWithContextInfo = useMemo(
-    () =>
-      priceFeeds.map((feed) => {
-        const contextFeed = feeds.get(feed.symbol);
-        if (contextFeed) {
-          return {
-            ...feed,
-            assetClass: contextFeed.assetClass,
-            displaySymbol: contextFeed.displaySymbol,
-            key: contextFeed.key[Cluster.Pythnet],
-          };
-        } else {
-          throw new NoSuchFeedError(feed.symbol);
-        }
-      }),
-    [feeds, priceFeeds],
-  );
   const feedsFilteredByAssetClass = useMemo(
     () =>
       assetClass
-        ? priceFeedsWithContextInfo.filter(
-            (feed) => feed.assetClass === assetClass,
-          )
-        : priceFeedsWithContextInfo,
-    [assetClass, priceFeedsWithContextInfo],
+        ? priceFeeds.filter((feed) => feed.assetClass === assetClass)
+        : priceFeeds,
+    [assetClass, priceFeeds],
   );
   const {
     search,
@@ -115,7 +98,16 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
   const rows = useMemo(
     () =>
       paginatedItems.map(
-        ({ displaySymbol, symbol, exponent, numQuoters, key }) => ({
+        ({
+          displaySymbol,
+          symbol,
+          exponent,
+          numQuoters,
+          key,
+          description,
+          icon,
+          assetClass,
+        }) => ({
           id: symbol,
           href: `/price-feeds/${encodeURIComponent(symbol)}`,
           textValue: displaySymbol,
@@ -140,8 +132,14 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
             confidenceInterval: (
               <LiveConfidence feedKey={key} cluster={Cluster.Pythnet} />
             ),
-            priceFeedName: <PriceFeedTag compact symbol={symbol} />,
-            assetClass: <AssetClassTag symbol={symbol} />,
+            priceFeedName: (
+              <PriceFeedTag
+                description={description}
+                displaySymbol={displaySymbol}
+                icon={icon}
+              />
+            ),
+            assetClass: <AssetClassBadge>{assetClass}</AssetClassBadge>,
             priceFeedId: (
               <FeedKey feedKey={key} className={styles.feedKey ?? ""} />
             ),
@@ -163,10 +161,10 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const assetClasses = useMemo(
     () =>
-      [
-        ...new Set(priceFeedsWithContextInfo.map((feed) => feed.assetClass)),
-      ].sort((a, b) => collator.compare(a, b)),
-    [priceFeedsWithContextInfo, collator],
+      [...new Set(priceFeeds.map((feed) => feed.assetClass))].sort((a, b) =>
+        collator.compare(a, b),
+      ),
+    [priceFeeds, collator],
   );
 
   return (
@@ -292,7 +290,7 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
     <EntityList
       label="Price Feeds"
       className={styles.entityList ?? ""}
-      headerLoadingSkeleton={<PriceFeedTag compact isLoading />}
+      headerLoadingSkeleton={<PriceFeedTag isLoading />}
       fields={[
         { id: "assetClass", name: "Asset Class" },
         { id: "priceFeedId", name: "Price Feed ID" },
@@ -327,7 +325,7 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
           name: "PRICE FEED",
           isRowHeader: true,
           alignment: "left",
-          loadingSkeleton: <PriceFeedTag compact isLoading />,
+          loadingSkeleton: <PriceFeedTag isLoading />,
           allowsSorting: true,
         },
         {
@@ -392,10 +390,3 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
     />
   </Card>
 );
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

+ 0 - 3
apps/insights/src/components/Publisher/get-price-feeds.tsx

@@ -14,9 +14,6 @@ export const getPriceFeeds = async (cluster: Cluster, key: string) => {
         ranking.symbol === feed.symbol &&
         ranking.cluster === ClusterToName[cluster],
     );
-    //if (!ranking) {
-    //  console.log(`No ranking for feed: ${feed.symbol} in cluster ${ClusterToName[cluster]}`);
-    //}
     return {
       ranking,
       feed,

+ 518 - 324
apps/insights/src/components/Publisher/layout.tsx

@@ -5,25 +5,22 @@ import { ShieldChevron } from "@phosphor-icons/react/dist/ssr/ShieldChevron";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
 import { Button } from "@pythnetwork/component-library/Button";
-import { DrawerTrigger, Drawer } from "@pythnetwork/component-library/Drawer";
 import { InfoBox } from "@pythnetwork/component-library/InfoBox";
 import { Link } from "@pythnetwork/component-library/Link";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
 import type { ReactNode } from "react";
+import { Suspense } from "react";
 
-import { getPriceFeeds } from "./get-price-feeds";
-import styles from "./layout.module.scss";
-import { OisApyHistory } from "./ois-apy-history";
-import { PriceFeedDrawerProvider } from "./price-feed-drawer-provider";
 import {
   getPublisherRankingHistory,
   getPublisherAverageScoreHistory,
   getPublishers,
 } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { Cluster, ClusterToName, parseCluster } from "../../services/pyth";
+import { ClusterToName, parseCluster, Cluster } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
 import { Cards } from "../Cards";
 import { ChangePercent } from "../ChangePercent";
@@ -35,16 +32,19 @@ import {
   ExplainActive,
   ExplainInactive,
 } from "../Explanations";
-import { FormattedDate } from "../FormattedDate";
 import { FormattedNumber } from "../FormattedNumber";
-import { FormattedTokens } from "../FormattedTokens";
-import { Meter } from "../Meter";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherKey } from "../PublisherKey";
 import { PublisherTag } from "../PublisherTag";
+import { getPriceFeeds } from "./get-price-feeds";
+import styles from "./layout.module.scss";
+import { FormattedDate } from "../FormattedDate";
+import { FormattedTokens } from "../FormattedTokens";
+import { Meter } from "../Meter";
 import { SemicircleMeter } from "../SemicircleMeter";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 import { TokenIcon } from "../TokenIcon";
+import { OisApyHistory } from "./ois-apy-history";
 
 type Props = {
   children: ReactNode;
@@ -54,48 +54,15 @@ type Props = {
   }>;
 };
 
-export const PublishersLayout = async ({ children, params }: Props) => {
+export const PublisherLayout = async ({ children, params }: Props) => {
   const { cluster, key } = await params;
   const parsedCluster = parseCluster(cluster);
 
   if (parsedCluster === undefined) {
     notFound();
-  }
-
-  const [
-    rankingHistory,
-    averageScoreHistory,
-    oisStats,
-    priceFeeds,
-    publishers,
-  ] = await Promise.all([
-    getPublisherRankingHistory(parsedCluster, key),
-    getPublisherAverageScoreHistory(parsedCluster, key),
-    getOisStats(key),
-    getPriceFeeds(parsedCluster, key),
-    getPublishers(parsedCluster),
-  ]);
-
-  const currentRanking = rankingHistory.at(-1);
-  const previousRanking = rankingHistory.at(-2);
-
-  const currentAverageScore = averageScoreHistory.at(-1);
-  const previousAverageScore = averageScoreHistory.at(-2);
-  const knownPublisher = lookup(key);
-  const publisher = publishers.find((publisher) => publisher.key === key);
-
-  return publisher && currentRanking && currentAverageScore ? (
-    <PriceFeedDrawerProvider
-      cluster={parsedCluster}
-      publisherKey={key}
-      priceFeeds={priceFeeds.map(({ feed, ranking, status }) => ({
-        symbol: feed.symbol,
-        score: ranking?.final_score,
-        rank: ranking?.final_rank,
-        firstEvaluation: ranking?.first_ranking_time,
-        status,
-      }))}
-    >
+  } else {
+    const knownPublisher = lookup(key);
+    return (
       <div className={styles.publisherLayout}>
         <section className={styles.header}>
           <div className={styles.breadcrumbRow}>
@@ -118,283 +85,40 @@ export const PublishersLayout = async ({ children, params }: Props) => {
             })}
           />
           <Cards className={styles.stats ?? ""}>
-            <ChartCard
-              variant="primary"
-              header="Publisher Ranking"
-              corner={
-                <Explain size="xs" title="Publisher Ranking">
-                  <p>
-                    Each <b>Publisher</b> receives a <b>Ranking</b> which is
-                    derived from the number of price feeds the <b>Publisher</b>{" "}
-                    is actively publishing.
-                  </p>
-                </Explain>
-              }
-              data={rankingHistory.map(({ timestamp, rank }) => ({
-                x: timestamp,
-                y: rank,
-                displayX: (
-                  <span className={styles.activeDate}>
-                    <FormattedDate value={timestamp} />
-                  </span>
-                ),
-              }))}
-              stat={currentRanking.rank}
-              {...(previousRanking && {
-                miniStat: (
-                  <ChangeValue
-                    direction={getChangeDirection(
-                      currentRanking.rank,
-                      previousRanking.rank,
-                    )}
-                  >
-                    {Math.abs(currentRanking.rank - previousRanking.rank)}
-                  </ChangeValue>
-                ),
-              })}
-            />
-            <ChartCard
-              header="Average Score"
-              corner={<ExplainAverage />}
-              data={averageScoreHistory.map(({ time, averageScore }) => ({
-                x: time,
-                y: averageScore,
-                displayX: (
-                  <span className={styles.activeDate}>
-                    <FormattedDate value={time} />
-                  </span>
-                ),
-                displayY: (
-                  <FormattedNumber
-                    maximumSignificantDigits={5}
-                    value={averageScore}
-                  />
-                ),
-              }))}
-              stat={
-                <FormattedNumber
-                  maximumSignificantDigits={5}
-                  value={currentAverageScore.averageScore}
-                />
-              }
-              {...(previousAverageScore && {
-                miniStat: (
-                  <ChangePercent
-                    currentValue={currentAverageScore.averageScore}
-                    previousValue={previousAverageScore.averageScore}
-                  />
-                ),
-              })}
-            />
-            <StatCard
-              header1={
-                <>
-                  Active Feeds
-                  <ExplainActive />
-                </>
-              }
-              header2={
-                <>
-                  <ExplainInactive />
-                  Inactive Feeds
-                </>
-              }
-              stat1={
-                <Link
-                  href={`/publishers/${ClusterToName[parsedCluster]}/${key}/price-feeds?status=Active`}
-                  invert
-                >
-                  {publisher.activeFeeds}
-                </Link>
-              }
-              stat2={
-                <Link
-                  href={`/publishers/${ClusterToName[parsedCluster]}/${key}/price-feeds?status=Inactive`}
-                  invert
-                >
-                  {publisher.inactiveFeeds}
-                </Link>
-              }
-              miniStat1={
-                <>
-                  <FormattedNumber
-                    maximumFractionDigits={1}
-                    value={(100 * publisher.activeFeeds) / priceFeeds.length}
-                  />
-                  %
-                </>
-              }
-              miniStat2={
-                <>
-                  <FormattedNumber
-                    maximumFractionDigits={1}
-                    value={(100 * publisher.inactiveFeeds) / priceFeeds.length}
-                  />
-                  %
-                </>
-              }
-            >
-              <Meter
-                value={publisher.activeFeeds}
-                maxValue={priceFeeds.length}
-                label="Active Feeds"
-              />
-            </StatCard>
+            <Suspense fallback={<RankingCardImpl isLoading />}>
+              <RankingCard cluster={parsedCluster} publisherKey={key} />
+            </Suspense>
+            <Suspense fallback={<ScoreCardImpl isLoading />}>
+              <ScoreCard cluster={parsedCluster} publisherKey={key} />
+            </Suspense>
+            <Suspense fallback={<ActiveFeedsCardImpl isLoading />}>
+              <ActiveFeedsCard cluster={parsedCluster} publisherKey={key} />
+            </Suspense>
             {parsedCluster === Cluster.Pythnet && (
-              <DrawerTrigger>
-                <StatCard
-                  header="OIS Pool Allocation"
-                  stat={
-                    <span
-                      className={styles.oisAllocation}
-                      data-is-overallocated={
-                        Number(oisStats.poolUtilization) > oisStats.maxPoolSize
-                          ? ""
-                          : undefined
-                      }
-                    >
-                      <FormattedNumber
-                        maximumFractionDigits={2}
-                        value={
-                          (100 * Number(oisStats.poolUtilization)) /
-                          oisStats.maxPoolSize
-                        }
-                      />
-                      %
-                    </span>
-                  }
-                  corner={<ArrowsOutSimple />}
-                >
-                  <Meter
-                    value={Number(oisStats.poolUtilization)}
-                    maxValue={oisStats.maxPoolSize}
-                    label="OIS Pool"
-                    startLabel={
-                      <span className={styles.tokens}>
-                        <TokenIcon />
-                        <span>
-                          <FormattedTokens tokens={oisStats.poolUtilization} />
-                        </span>
-                      </span>
-                    }
-                    endLabel={
-                      <span className={styles.tokens}>
-                        <TokenIcon />
-                        <span>
-                          <FormattedTokens
-                            tokens={BigInt(oisStats.maxPoolSize)}
-                          />
-                        </span>
-                      </span>
-                    }
-                  />
-                </StatCard>
-                <Drawer
-                  title="OIS Pool Allocation"
-                  className={styles.oisDrawer ?? ""}
-                  bodyClassName={styles.oisDrawerBody}
-                  footerClassName={styles.oisDrawerFooter}
-                  footer={
-                    <>
-                      <Button
-                        variant="solid"
-                        size="sm"
-                        href="https://staking.pyth.network"
-                        target="_blank"
-                        beforeIcon={Browsers}
-                      >
-                        Open Staking App
-                      </Button>
-                      <Button
-                        variant="outline"
-                        size="sm"
-                        href="https://docs.pyth.network/home/oracle-integrity-staking"
-                        target="_blank"
-                        beforeIcon={BookOpenText}
-                      >
-                        Documentation
-                      </Button>
-                    </>
-                  }
-                >
-                  <SemicircleMeter
-                    width={260}
-                    height={310}
-                    value={Number(oisStats.poolUtilization)}
-                    maxValue={oisStats.maxPoolSize}
-                    className={styles.smallOisMeter ?? ""}
-                    aria-label="OIS Pool Utilization"
-                  >
-                    <TokenIcon className={styles.oisMeterIcon} />
-                    <div className={styles.oisMeterLabel}>OIS Pool</div>
-                  </SemicircleMeter>
-                  <SemicircleMeter
-                    width={420}
-                    height={420}
-                    value={Number(oisStats.poolUtilization)}
-                    maxValue={oisStats.maxPoolSize}
-                    className={styles.oisMeter ?? ""}
-                    aria-label="OIS Pool Utilization"
-                  >
-                    <TokenIcon className={styles.oisMeterIcon} />
-                    <div className={styles.oisMeterLabel}>OIS Pool</div>
-                  </SemicircleMeter>
-                  <StatCard
-                    header="Total Staked"
-                    variant="secondary"
-                    nonInteractive
-                    stat={
-                      <>
-                        <TokenIcon />
-                        <FormattedTokens tokens={oisStats.poolUtilization} />
-                      </>
-                    }
-                  />
-                  <StatCard
-                    header="Pool Capacity"
-                    variant="secondary"
-                    nonInteractive
-                    stat={
-                      <>
-                        <TokenIcon />
-                        <FormattedTokens
-                          tokens={BigInt(oisStats.maxPoolSize)}
-                        />
-                      </>
-                    }
-                  />
-                  <OisApyHistory apyHistory={oisStats.apyHistory ?? []} />
-                  <InfoBox
-                    className={styles.oisInfoBox}
-                    icon={<ShieldChevron />}
-                    header="Oracle Integrity Staking (OIS)"
-                  >
-                    OIS allows anyone to help secure Pyth and protect DeFi.
-                    Through decentralized staking rewards and slashing, OIS
-                    incentivizes Pyth publishers to maintain high-quality data
-                    contributions. PYTH holders can stake to publishers to
-                    further reinforce oracle security. Rewards are
-                    programmatically distributed to high quality publishers and
-                    the stakers supporting them to strengthen oracle integrity.
-                  </InfoBox>
-                </Drawer>
-              </DrawerTrigger>
+              <Suspense fallback={<OisPoolCardImpl isLoading />}>
+                <OisPoolCard publisherKey={key} />
+              </Suspense>
             )}
           </Cards>
         </section>
         <TabRoot>
           <Tabs
             label="Price Feed Navigation"
-            prefix={`/publishers/${ClusterToName[parsedCluster]}/${key}`}
             items={[
-              { segment: undefined, children: "Performance" },
+              {
+                id: "(performance)",
+                segment: undefined,
+                children: "Performance",
+              },
               {
                 segment: "price-feeds",
                 children: (
                   <div className={styles.priceFeedsTabLabel}>
                     <span>Price Feeds</span>
                     <Badge size="xs" style="filled" variant="neutral">
-                      {priceFeeds.length}
+                      <Suspense>
+                        <NumFeeds cluster={parsedCluster} publisherKey={key} />
+                      </Suspense>
                     </Badge>
                   </div>
                 ),
@@ -404,9 +128,195 @@ export const PublishersLayout = async ({ children, params }: Props) => {
           <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
         </TabRoot>
       </div>
-    </PriceFeedDrawerProvider>
-  ) : (
-    notFound()
+    );
+  }
+};
+
+const NumFeeds = async ({
+  cluster,
+  publisherKey,
+}: {
+  cluster: Cluster;
+  publisherKey: string;
+}) => {
+  const feeds = await getPriceFeeds(cluster, publisherKey);
+  return feeds.length;
+};
+
+const RankingCard = async ({
+  cluster,
+  publisherKey,
+}: {
+  cluster: Cluster;
+  publisherKey: string;
+}) => {
+  const rankingHistory = await getPublisherRankingHistory(
+    cluster,
+    publisherKey,
+  );
+  return <RankingCardImpl rankingHistory={rankingHistory} />;
+};
+
+type RankingCardImplProps =
+  | {
+      isLoading: true;
+    }
+  | {
+      isLoading?: false | undefined;
+      rankingHistory: {
+        timestamp: Date;
+        rank: number;
+      }[];
+    };
+
+const RankingCardImpl = (props: RankingCardImplProps) => (
+  <ChartCard
+    variant="primary"
+    header="Publisher Ranking"
+    corner={
+      <Explain size="xs" title="Publisher Ranking">
+        <p>
+          Each <b>Publisher</b> receives a <b>Ranking</b> which is derived from
+          the number of price feeds the <b>Publisher</b> is actively publishing.
+        </p>
+      </Explain>
+    }
+    data={
+      props.isLoading
+        ? []
+        : props.rankingHistory.map(({ timestamp, rank }) => ({
+            x: timestamp,
+            y: rank,
+            displayX: (
+              <span className={styles.activeDate}>
+                <FormattedDate value={timestamp} />
+              </span>
+            ),
+          }))
+    }
+    stat={
+      props.isLoading ? (
+        <Skeleton width={20} />
+      ) : (
+        props.rankingHistory.at(-1)?.rank
+      )
+    }
+    miniStat={
+      props.isLoading ? (
+        <Skeleton width={14} />
+      ) : (
+        <RankingChange rankingHistory={props.rankingHistory} />
+      )
+    }
+  />
+);
+
+const RankingChange = ({
+  rankingHistory,
+}: {
+  rankingHistory: { rank: number }[];
+}) => {
+  const current = rankingHistory.at(-1)?.rank;
+  const prev = rankingHistory.at(-2)?.rank;
+
+  // eslint-disable-next-line unicorn/no-null
+  return current === undefined || prev === undefined ? null : (
+    <ChangeValue direction={getChangeDirection(current, prev)}>
+      {Math.abs(current - prev)}
+    </ChangeValue>
+  );
+};
+
+const ScoreCard = async ({
+  cluster,
+  publisherKey,
+}: {
+  cluster: Cluster;
+  publisherKey: string;
+}) => {
+  const averageScoreHistory = await getPublisherAverageScoreHistory(
+    cluster,
+    publisherKey,
+  );
+  return <ScoreCardImpl averageScoreHistory={averageScoreHistory} />;
+};
+
+type ScoreCardImplProps =
+  | {
+      isLoading: true;
+    }
+  | {
+      isLoading?: false | undefined;
+      averageScoreHistory: {
+        time: Date;
+        averageScore: number;
+      }[];
+    };
+
+const ScoreCardImpl = (props: ScoreCardImplProps) => (
+  <ChartCard
+    header="Average Score"
+    corner={<ExplainAverage />}
+    data={
+      props.isLoading
+        ? []
+        : props.averageScoreHistory.map(({ time, averageScore }) => ({
+            x: time,
+            y: averageScore,
+            displayX: (
+              <span className={styles.activeDate}>
+                <FormattedDate value={time} />
+              </span>
+            ),
+            displayY: (
+              <FormattedNumber
+                maximumSignificantDigits={5}
+                value={averageScore}
+              />
+            ),
+          }))
+    }
+    stat={
+      props.isLoading ? (
+        <Skeleton width={20} />
+      ) : (
+        <CurrentAverageScore averageScoreHistory={props.averageScoreHistory} />
+      )
+    }
+    miniStat={
+      props.isLoading ? (
+        <Skeleton width={20} />
+      ) : (
+        <ScoreChange averageScoreHistory={props.averageScoreHistory} />
+      )
+    }
+  />
+);
+
+const CurrentAverageScore = ({
+  averageScoreHistory,
+}: {
+  averageScoreHistory: { averageScore: number }[];
+}) => {
+  const currentAverageScore = averageScoreHistory.at(-1)?.averageScore;
+
+  // eslint-disable-next-line unicorn/no-null
+  return currentAverageScore === undefined ? null : (
+    <FormattedNumber maximumSignificantDigits={5} value={currentAverageScore} />
+  );
+};
+
+const ScoreChange = ({
+  averageScoreHistory,
+}: {
+  averageScoreHistory: { averageScore: number }[];
+}) => {
+  const current = averageScoreHistory.at(-1)?.averageScore;
+  const prev = averageScoreHistory.at(-2)?.averageScore;
+
+  // eslint-disable-next-line unicorn/no-null
+  return current === undefined || prev === undefined ? null : (
+    <ChangePercent currentValue={current} previousValue={prev} />
   );
 };
 
@@ -420,24 +330,308 @@ const getChangeDirection = (previousValue: number, currentValue: number) => {
   }
 };
 
-const getOisStats = async (key: string) => {
+const ActiveFeedsCard = async ({
+  cluster,
+  publisherKey,
+}: {
+  cluster: Cluster;
+  publisherKey: string;
+}) => {
+  const [publishers, priceFeeds] = await Promise.all([
+    getPublishers(cluster),
+    getPriceFeeds(cluster, publisherKey),
+  ]);
+  const publisher = publishers.find(
+    (publisher) => publisher.key === publisherKey,
+  );
+
+  return publisher ? (
+    <ActiveFeedsCardImpl
+      cluster={cluster}
+      publisherKey={publisherKey}
+      activeFeeds={publisher.activeFeeds}
+      inactiveFeeds={publisher.inactiveFeeds}
+      allFeeds={priceFeeds.length}
+    />
+  ) : (
+    notFound()
+  );
+};
+
+type ActiveFeedsCardImplProps =
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      cluster: Cluster;
+      publisherKey: string;
+      activeFeeds: number;
+      inactiveFeeds: number;
+      allFeeds: number;
+    };
+
+const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => (
+  <StatCard
+    header1={
+      <>
+        Active Feeds
+        <ExplainActive />
+      </>
+    }
+    header2={
+      <>
+        <ExplainInactive />
+        Inactive Feeds
+      </>
+    }
+    stat1={
+      props.isLoading ? (
+        <Skeleton width={10} />
+      ) : (
+        <Link
+          href={`/publishers/${ClusterToName[props.cluster]}/${props.publisherKey}/price-feeds?status=Active`}
+          invert
+        >
+          {props.activeFeeds}
+        </Link>
+      )
+    }
+    stat2={
+      props.isLoading ? (
+        <Skeleton width={10} />
+      ) : (
+        <Link
+          href={`/publishers/${ClusterToName[props.cluster]}/${props.publisherKey}/price-feeds?status=Inactive`}
+          invert
+        >
+          {props.inactiveFeeds}
+        </Link>
+      )
+    }
+    miniStat1={
+      props.isLoading ? (
+        <Skeleton width={10} />
+      ) : (
+        <>
+          <FormattedNumber
+            maximumFractionDigits={1}
+            value={(100 * props.activeFeeds) / props.allFeeds}
+          />
+          %
+        </>
+      )
+    }
+    miniStat2={
+      props.isLoading ? (
+        <Skeleton width={10} />
+      ) : (
+        <>
+          <FormattedNumber
+            maximumFractionDigits={1}
+            value={(100 * props.inactiveFeeds) / props.allFeeds}
+          />
+          %
+        </>
+      )
+    }
+  >
+    {!props.isLoading && (
+      <Meter
+        value={props.activeFeeds}
+        maxValue={props.allFeeds}
+        label="Active Feeds"
+      />
+    )}
+  </StatCard>
+);
+
+const OisPoolCard = async ({ publisherKey }: { publisherKey: string }) => {
   const [publisherPoolData, publisherCaps] = await Promise.all([
     getPublisherPoolData(),
     getPublisherCaps(),
   ]);
 
   const publisher = publisherPoolData.find(
-    (publisher) => publisher.pubkey === key,
+    (publisher) => publisher.pubkey === publisherKey,
   );
 
-  return {
-    apyHistory: publisher?.apyHistory,
-    poolUtilization:
-      (publisher?.totalDelegation ?? 0n) +
-      (publisher?.totalDelegationDelta ?? 0n),
-    maxPoolSize:
-      publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
-        ({ publisher }) => publisher === key,
-      )?.cap ?? 0,
-  };
+  return (
+    <OisPoolCardImpl
+      apyHistory={publisher?.apyHistory ?? []}
+      poolUtilization={
+        (publisher?.totalDelegation ?? 0n) +
+        (publisher?.totalDelegationDelta ?? 0n)
+      }
+      maxPoolSize={
+        publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
+          ({ publisher }) => publisher === publisherKey,
+        )?.cap ?? 0
+      }
+    />
+  );
 };
+
+type OisPoolCardImplProps =
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      apyHistory: { date: Date; apy: number }[];
+      poolUtilization: bigint;
+      maxPoolSize: number;
+    };
+
+const OisPoolCardImpl = (props: OisPoolCardImplProps) => (
+  <StatCard
+    header="OIS Pool Allocation"
+    drawer={{
+      title: "OIS Pool Allocation",
+      className: styles.oisDrawer ?? "",
+      bodyClassName: styles.oisDrawerBody,
+      footerClassName: styles.oisDrawerFooter,
+      footer: (
+        <>
+          <Button
+            variant="solid"
+            size="sm"
+            href="https://staking.pyth.network"
+            target="_blank"
+            beforeIcon={Browsers}
+          >
+            Open Staking App
+          </Button>
+          <Button
+            variant="outline"
+            size="sm"
+            href="https://docs.pyth.network/home/oracle-integrity-staking"
+            target="_blank"
+            beforeIcon={BookOpenText}
+          >
+            Documentation
+          </Button>
+        </>
+      ),
+      contents: (
+        <>
+          {!props.isLoading && (
+            <>
+              <SemicircleMeter
+                width={260}
+                height={310}
+                value={Number(props.poolUtilization)}
+                maxValue={props.maxPoolSize}
+                className={styles.smallOisMeter ?? ""}
+                aria-label="OIS Pool Utilization"
+              >
+                <TokenIcon className={styles.oisMeterIcon} />
+                <div className={styles.oisMeterLabel}>OIS Pool</div>
+              </SemicircleMeter>
+              <SemicircleMeter
+                width={420}
+                height={420}
+                value={Number(props.poolUtilization)}
+                maxValue={props.maxPoolSize}
+                className={styles.oisMeter ?? ""}
+                aria-label="OIS Pool Utilization"
+              >
+                <TokenIcon className={styles.oisMeterIcon} />
+                <div className={styles.oisMeterLabel}>OIS Pool</div>
+              </SemicircleMeter>
+            </>
+          )}
+          <StatCard
+            header="Total Staked"
+            variant="secondary"
+            nonInteractive
+            stat={
+              <>
+                <TokenIcon />
+                {props.isLoading ? (
+                  <Skeleton width={20} />
+                ) : (
+                  <FormattedTokens tokens={props.poolUtilization} />
+                )}
+              </>
+            }
+          />
+          <StatCard
+            header="Pool Capacity"
+            variant="secondary"
+            nonInteractive
+            stat={
+              <>
+                <TokenIcon />
+
+                {props.isLoading ? (
+                  <Skeleton width={20} />
+                ) : (
+                  <FormattedTokens tokens={BigInt(props.maxPoolSize)} />
+                )}
+              </>
+            }
+          />
+          <OisApyHistory apyHistory={props.isLoading ? [] : props.apyHistory} />
+          <InfoBox
+            className={styles.oisInfoBox}
+            icon={<ShieldChevron />}
+            header="Oracle Integrity Staking (OIS)"
+          >
+            OIS allows anyone to help secure Pyth and protect DeFi. Through
+            decentralized staking rewards and slashing, OIS incentivizes Pyth
+            publishers to maintain high-quality data contributions. PYTH holders
+            can stake to publishers to further reinforce oracle security.
+            Rewards are programmatically distributed to high quality publishers
+            and the stakers supporting them to strengthen oracle integrity.
+          </InfoBox>
+        </>
+      ),
+    }}
+    stat={
+      props.isLoading ? (
+        <Skeleton width={20} />
+      ) : (
+        <span
+          className={styles.oisAllocation}
+          data-is-overallocated={
+            Number(props.poolUtilization) > props.maxPoolSize ? "" : undefined
+          }
+        >
+          <FormattedNumber
+            maximumFractionDigits={2}
+            value={(100 * Number(props.poolUtilization)) / props.maxPoolSize}
+          />
+          %
+        </span>
+      )
+    }
+    corner={<ArrowsOutSimple />}
+  >
+    <Meter
+      value={props.isLoading ? 0 : Number(props.poolUtilization)}
+      maxValue={props.isLoading ? 0 : props.maxPoolSize}
+      label="OIS Pool"
+      startLabel={
+        <span className={styles.tokens}>
+          <TokenIcon />
+          <span>
+            {props.isLoading ? (
+              <Skeleton width={10} />
+            ) : (
+              <FormattedTokens tokens={props.poolUtilization} />
+            )}
+          </span>
+        </span>
+      }
+      endLabel={
+        <span className={styles.tokens}>
+          <TokenIcon />
+          <span>
+            {props.isLoading ? (
+              <Skeleton width={10} />
+            ) : (
+              <FormattedTokens tokens={BigInt(props.maxPoolSize)} />
+            )}
+          </span>
+        </span>
+      }
+    />
+  </StatCard>
+);

+ 2 - 2
apps/insights/src/components/Publisher/performance.module.scss

@@ -16,7 +16,7 @@
 
   .publishersRankingCard {
     .publishersRankingList {
-      @include theme.breakpoint("sm") {
+      @include theme.breakpoint("lg") {
         display: none;
       }
 
@@ -28,7 +28,7 @@
     .publishersRankingTable {
       display: none;
 
-      @include theme.breakpoint("sm") {
+      @include theme.breakpoint("lg") {
         display: unset;
       }
     }

+ 203 - 118
apps/insights/src/components/Publisher/performance.tsx

@@ -2,18 +2,18 @@ import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 import { Confetti } from "@phosphor-icons/react/dist/ssr/Confetti";
 import { Network } from "@phosphor-icons/react/dist/ssr/Network";
 import { SmileySad } from "@phosphor-icons/react/dist/ssr/SmileySad";
-import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Link } from "@pythnetwork/component-library/Link";
 import { Table } from "@pythnetwork/component-library/Table";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
-import type { ReactNode } from "react";
+import type { ReactNode, ComponentProps } from "react";
 
 import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./performance.module.scss";
 import { TopFeedsTable } from "./top-feeds-table";
 import { getPublishers } from "../../services/clickhouse";
+import type { Cluster } from "../../services/pyth";
 import { ClusterToName, parseCluster } from "../../services/pyth";
 import { Status } from "../../status";
 import { EntityList } from "../EntityList";
@@ -24,6 +24,7 @@ import {
 } from "../Explanations";
 import type { Variant as NoResultsVariant } from "../NoResults";
 import { NoResults } from "../NoResults";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherTag } from "../PublisherTag";
@@ -59,6 +60,7 @@ export const Performance = async ({ params }: Props) => {
     const knownPublisher = lookup(publisher.key);
     return {
       id: publisher.key,
+      prefetch: false,
       nameAsString: knownPublisher?.name ?? publisher.key,
       data: {
         ranking: (
@@ -70,6 +72,7 @@ export const Performance = async ({ params }: Props) => {
           <Link
             href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Active`}
             invert
+            prefetch={false}
           >
             {publisher.activeFeeds}
           </Link>
@@ -78,6 +81,7 @@ export const Performance = async ({ params }: Props) => {
           <Link
             href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Inactive`}
             invert
+            prefetch={false}
           >
             {publisher.inactiveFeeds}
           </Link>
@@ -119,101 +123,165 @@ export const Performance = async ({ params }: Props) => {
   return rows === undefined ? (
     notFound()
   ) : (
-    <div className={styles.performance}>
-      <Card
-        icon={<Broadcast />}
-        title="Publishers Ranking"
-        className={styles.publishersRankingCard ?? ""}
-      >
-        <EntityList
-          label="Publishers Ranking"
-          className={styles.publishersRankingList ?? ""}
-          fields={[
-            { id: "ranking", name: "Ranking" },
-            { id: "averageScore", name: "Average Score" },
-            { id: "activeFeeds", name: "Active Feeds" },
-            { id: "inactiveFeeds", name: "Inactive Feeds" },
-          ]}
-          rows={rows.map((row) => ({
-            ...row,
-            textValue: row.nameAsString,
-            header: row.data.name,
-          }))}
-        />
-        <Table
-          rounded
-          fill
-          className={styles.publishersRankingTable ?? ""}
-          label="Publishers Ranking"
-          columns={[
-            {
-              id: "ranking",
-              name: "RANKING",
-              width: 25,
-            },
-            {
-              id: "name",
-              name: "NAME / ID",
-              isRowHeader: true,
-              alignment: "left",
-            },
-            {
-              id: "activeFeeds",
-              name: (
-                <>
-                  ACTIVE FEEDS
-                  <ExplainActive />
-                </>
-              ),
-              alignment: "center",
-              width: 30,
-            },
-            {
-              id: "inactiveFeeds",
-              name: (
-                <>
-                  INACTIVE FEEDS
-                  <ExplainInactive />
-                </>
-              ),
-              alignment: "center",
-              width: 30,
-            },
-            {
-              id: "averageScore",
-              name: (
-                <>
-                  AVERAGE SCORE
-                  <ExplainAverage scoreTime={publishers[0]?.scoreTime} />
-                </>
-              ),
-              alignment: "right",
-              width: PUBLISHER_SCORE_WIDTH,
-            },
-          ]}
-          rows={rows}
-        />
-      </Card>
-      <TopFeedsCard
-        title="High-Performing"
-        emptyIcon={<SmileySad />}
-        emptyHeader="Oh no!"
-        emptyBody="This publisher has no high performing feeds"
-        emptyVariant="error"
-        feeds={highPerformingFeeds}
-      />
-      <TopFeedsCard
-        title="Low-Performing"
-        emptyIcon={<Confetti />}
-        emptyHeader="Looking good!"
-        emptyBody="This publisher has no low performing feeds"
-        emptyVariant="success"
-        feeds={lowPerformingFeeds}
-      />
-    </div>
+    <PerformanceImpl
+      publishers={rows}
+      highPerformingFeeds={highPerformingFeeds}
+      lowPerformingFeeds={lowPerformingFeeds}
+      averageScoreTime={publishers[0]?.scoreTime}
+      publisherKey={key}
+      cluster={parsedCluster}
+    />
   );
 };
 
+export const PerformanceLoading = () => <PerformanceImpl isLoading />;
+
+type PerformanceImplProps =
+  | { isLoading: true }
+  | {
+      isLoading?: false;
+      publisherKey: string;
+      cluster: Cluster;
+      publishers: (NonNullable<
+        ComponentProps<
+          typeof Table<
+            | "ranking"
+            | "averageScore"
+            | "activeFeeds"
+            | "inactiveFeeds"
+            | "name"
+          >
+        >["rows"]
+      >[number] & {
+        prefetch: boolean;
+        nameAsString: string;
+      })[];
+      highPerformingFeeds: ReturnType<typeof getFeedRows>;
+      lowPerformingFeeds: ReturnType<typeof getFeedRows>;
+      averageScoreTime?: Date | undefined;
+    };
+
+const PerformanceImpl = (props: PerformanceImplProps) => (
+  <div className={styles.performance}>
+    <Card
+      icon={<Broadcast />}
+      title="Publishers Ranking"
+      className={styles.publishersRankingCard ?? ""}
+    >
+      <EntityList
+        label="Publishers Ranking"
+        className={styles.publishersRankingList ?? ""}
+        headerLoadingSkeleton={<PublisherTag isLoading />}
+        fields={[
+          { id: "ranking", name: "Ranking" },
+          { id: "averageScore", name: "Average Score" },
+          { id: "activeFeeds", name: "Active Feeds" },
+          { id: "inactiveFeeds", name: "Inactive Feeds" },
+        ]}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              rows: props.publishers.map((publisher) => ({
+                ...publisher,
+                textValue: publisher.nameAsString,
+                header: publisher.data.name,
+              })),
+            })}
+      />
+      <Table
+        rounded
+        fill
+        className={styles.publishersRankingTable ?? ""}
+        label="Publishers Ranking"
+        columns={[
+          {
+            id: "ranking",
+            name: "RANKING",
+            width: 25,
+          },
+          {
+            id: "name",
+            name: "NAME / ID",
+            isRowHeader: true,
+            alignment: "left",
+            loadingSkeleton: <PublisherTag isLoading />,
+          },
+          {
+            id: "activeFeeds",
+            name: (
+              <>
+                ACTIVE FEEDS
+                <ExplainActive />
+              </>
+            ),
+            alignment: "center",
+            width: 30,
+          },
+          {
+            id: "inactiveFeeds",
+            name: (
+              <>
+                INACTIVE FEEDS
+                <ExplainInactive />
+              </>
+            ),
+            alignment: "center",
+            width: 30,
+          },
+          {
+            id: "averageScore",
+            name: (
+              <>
+                AVERAGE SCORE
+                <ExplainAverage
+                  {...(!props.isLoading && {
+                    scoreTime: props.averageScoreTime,
+                  })}
+                />
+              </>
+            ),
+            alignment: "right",
+            width: PUBLISHER_SCORE_WIDTH,
+          },
+        ]}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              rows: props.publishers,
+            })}
+      />
+    </Card>
+    <TopFeedsCard
+      title="High-Performing"
+      emptyIcon={<SmileySad />}
+      emptyHeader="Oh no!"
+      emptyBody="This publisher has no high performing feeds"
+      emptyVariant="error"
+      {...(props.isLoading
+        ? { isLoading: true }
+        : {
+            publisherKey: props.publisherKey,
+            cluster: props.cluster,
+            feeds: props.highPerformingFeeds,
+          })}
+    />
+    <TopFeedsCard
+      title="Low-Performing"
+      emptyIcon={<Confetti />}
+      emptyHeader="Looking good!"
+      emptyBody="This publisher has no low performing feeds"
+      emptyVariant="success"
+      {...(props.isLoading
+        ? { isLoading: true }
+        : {
+            publisherKey: props.publisherKey,
+            cluster: props.cluster,
+            feeds: props.lowPerformingFeeds,
+          })}
+    />
+  </div>
+);
+
 const getFeedRows = (
   priceFeeds: (Omit<
     Awaited<ReturnType<typeof getPriceFeeds>>,
@@ -227,20 +295,23 @@ const getFeedRows = (
   priceFeeds
     .filter((feed) => feed.status === Status.Active)
     .slice(0, 20)
-    .map(({ feed, ranking }) => ({
-      id: feed.symbol,
-      textValue: feed.symbol,
-      data: {
-        asset: <PriceFeedTag compact symbol={feed.symbol} />,
-        assetClass: (
-          <Badge variant="neutral" style="outline" size="xs">
-            {feed.product.asset_type.toUpperCase()}
-          </Badge>
-        ),
-        score: (
-          <Score width={PUBLISHER_SCORE_WIDTH} score={ranking.final_score} />
-        ),
-      },
+    .map(({ feed, ranking, status }) => ({
+      key: feed.product.price_account,
+      symbol: feed.symbol,
+      displaySymbol: feed.product.display_symbol,
+      description: feed.product.description,
+      assetClass: feed.product.asset_type,
+      score: ranking.final_score,
+      rank: ranking.final_rank,
+      status,
+      firstEvaluation: ranking.first_ranking_time,
+      icon: (
+        <PriceFeedIcon
+          assetClass={feed.product.asset_type}
+          symbol={feed.symbol}
+        />
+      ),
+      href: `/price-feeds/${encodeURIComponent(feed.symbol)}`,
     }));
 
 const sliceAround = <T,>(
@@ -271,8 +342,15 @@ type TopFeedsCardProps = {
   emptyHeader: string;
   emptyBody: string;
   emptyVariant: NoResultsVariant;
-  feeds: ReturnType<typeof getFeedRows>;
-};
+} & (
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      publisherKey: string;
+      cluster: Cluster;
+      feeds: ReturnType<typeof getFeedRows>;
+    }
+);
 
 const TopFeedsCard = ({
   title,
@@ -280,22 +358,29 @@ const TopFeedsCard = ({
   emptyHeader,
   emptyBody,
   emptyVariant,
-  feeds,
+  ...props
 }: TopFeedsCardProps) => (
   <Card icon={<Network />} title={`${title} Feeds`}>
-    {feeds.length === 0 ? (
+    {props.isLoading || props.feeds.length > 0 ? (
+      <TopFeedsTable
+        label={`${title} Feeds`}
+        publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
+        nameLoadingSkeleton={<PriceFeedTag isLoading />}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              feeds: props.feeds,
+              publisherKey: props.publisherKey,
+              cluster: props.cluster,
+            })}
+      />
+    ) : (
       <NoResults
         icon={emptyIcon}
         header={emptyHeader}
         body={emptyBody}
         variant={emptyVariant}
       />
-    ) : (
-      <TopFeedsTable
-        label={`${title} Feeds`}
-        publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
-        rows={feeds}
-      />
     )}
   </Card>
 );

+ 0 - 113
apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx

@@ -1,113 +0,0 @@
-"use client";
-
-import { useLogger } from "@pythnetwork/app-logger";
-import { parseAsString, useQueryState } from "nuqs";
-import type { ComponentProps } from "react";
-import { Suspense, createContext, useMemo, useCallback, use } from "react";
-
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import type { Cluster } from "../../services/pyth";
-import type { Status } from "../../status";
-import { PriceComponentDrawer } from "../PriceComponentDrawer";
-import { PriceFeedTag } from "../PriceFeedTag";
-
-const PriceFeedDrawerContext = createContext<
-  ((symbol: string) => void) | undefined
->(undefined);
-
-type PriceFeedDrawerProviderProps = Omit<
-  ComponentProps<typeof PriceFeedDrawerContext>,
-  "value"
-> & {
-  publisherKey: string;
-  cluster: Cluster;
-  priceFeeds: PriceFeed[];
-};
-
-type PriceFeed = {
-  symbol: string;
-  score: number | undefined;
-  rank: number | undefined;
-  status: Status;
-  firstEvaluation: Date | undefined;
-};
-
-export const PriceFeedDrawerProvider = (
-  props: PriceFeedDrawerProviderProps,
-) => (
-  <Suspense fallback={props.children}>
-    <PriceFeedDrawerProviderImpl {...props} />
-  </Suspense>
-);
-
-const PriceFeedDrawerProviderImpl = ({
-  publisherKey,
-  priceFeeds,
-  children,
-  cluster,
-}: PriceFeedDrawerProviderProps) => {
-  const contextPriceFeeds = usePriceFeeds();
-  const logger = useLogger();
-  const [selectedSymbol, setSelectedSymbol] = useQueryState(
-    "price-feed",
-    parseAsString.withDefault("").withOptions({
-      history: "push",
-    }),
-  );
-  const updateSelectedSymbol = useCallback(
-    (newSymbol: string) => {
-      setSelectedSymbol(newSymbol).catch((error: unknown) => {
-        logger.error("Failed to update selected symbol", error);
-      });
-    },
-    [setSelectedSymbol, logger],
-  );
-  const selectedFeed = useMemo(() => {
-    if (selectedSymbol === "") {
-      return;
-    } else {
-      const feed = priceFeeds.find((feed) => feed.symbol === selectedSymbol);
-      const contextFeed = contextPriceFeeds.get(selectedSymbol);
-
-      return feed === undefined || contextFeed === undefined
-        ? undefined
-        : {
-            ...feed,
-            ...contextFeed,
-            feedKey: contextFeed.key[cluster],
-          };
-    }
-  }, [selectedSymbol, priceFeeds, contextPriceFeeds, cluster]);
-  const handleClose = useCallback(() => {
-    updateSelectedSymbol("");
-  }, [updateSelectedSymbol]);
-  const feedHref = useMemo(
-    () => `/price-feeds/${encodeURIComponent(selectedFeed?.symbol ?? "")}`,
-    [selectedFeed],
-  );
-
-  return (
-    <PriceFeedDrawerContext value={updateSelectedSymbol}>
-      {children}
-      {selectedFeed && (
-        <PriceComponentDrawer
-          publisherKey={publisherKey}
-          onClose={handleClose}
-          feedKey={selectedFeed.feedKey}
-          rank={selectedFeed.rank}
-          score={selectedFeed.score}
-          symbol={selectedFeed.symbol}
-          displaySymbol={selectedFeed.displaySymbol}
-          status={selectedFeed.status}
-          firstEvaluation={selectedFeed.firstEvaluation ?? new Date()}
-          navigateHref={feedHref}
-          title={<PriceFeedTag symbol={selectedFeed.symbol} />}
-          cluster={cluster}
-          assetClass={selectedFeed.assetClass}
-        />
-      )}
-    </PriceFeedDrawerContext>
-  );
-};
-
-export const useSelectPriceFeed = () => use(PriceFeedDrawerContext);

+ 0 - 83
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -1,83 +0,0 @@
-"use client";
-
-import type { ComponentProps } from "react";
-import { useCallback } from "react";
-
-import { useSelectPriceFeed } from "./price-feed-drawer-provider";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import type { Cluster } from "../../services/pyth";
-import { AssetClassTag } from "../AssetClassTag";
-import { PriceComponentsCard } from "../PriceComponentsCard";
-import { PriceFeedTag } from "../PriceFeedTag";
-
-type Props = Omit<
-  ComponentProps<typeof PriceComponentsCard>,
-  "onPriceComponentAction" | "priceComponents"
-> & {
-  publisherKey: string;
-  cluster: Cluster;
-  priceFeeds: (Pick<
-    ComponentProps<typeof PriceComponentsCard>["priceComponents"][number],
-    "score" | "uptimeScore" | "deviationScore" | "stalledScore" | "status"
-  > & {
-    symbol: string;
-  })[];
-};
-
-export const PriceFeedsCard = ({
-  priceFeeds,
-  publisherKey,
-  cluster,
-  ...props
-}: Props) => {
-  const feeds = usePriceFeeds();
-  const selectPriceFeed = useSelectPriceFeed();
-  const onPriceComponentAction = useCallback(
-    ({ symbol }: { symbol: string }) => selectPriceFeed?.(symbol),
-    [selectPriceFeed],
-  );
-  return (
-    <PriceComponentsCard
-      onPriceComponentAction={onPriceComponentAction}
-      extraColumns={[
-        {
-          id: "assetClass",
-          name: "ASSET CLASS",
-          alignment: "left",
-          allowsSorting: true,
-        },
-      ]}
-      nameWidth={90}
-      priceComponents={priceFeeds.map((feed) => {
-        const contextFeed = feeds.get(feed.symbol);
-        if (contextFeed) {
-          return {
-            id: contextFeed.key[cluster],
-            feedKey: contextFeed.key[cluster],
-            symbol: feed.symbol,
-            score: feed.score,
-            uptimeScore: feed.uptimeScore,
-            deviationScore: feed.deviationScore,
-            stalledScore: feed.stalledScore,
-            cluster,
-            status: feed.status,
-            publisherKey,
-            name: <PriceFeedTag compact symbol={feed.symbol} />,
-            nameAsString: contextFeed.displaySymbol,
-            assetClass: <AssetClassTag symbol={feed.symbol} />,
-          };
-        } else {
-          throw new NoSuchFeedError(feed.symbol);
-        }
-      })}
-      {...props}
-    />
-  );
-};
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

+ 67 - 4
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -1,8 +1,12 @@
 import { notFound } from "next/navigation";
 
 import { getPriceFeeds } from "./get-price-feeds";
-import { PriceFeedsCard } from "./price-feeds-card";
+import type { Cluster } from "../../services/pyth";
 import { parseCluster } from "../../services/pyth";
+import { AssetClassBadge } from "../AssetClassBadge";
+import type { PriceComponent } from "../PriceComponentsCard";
+import { PriceComponentsCard } from "../PriceComponentsCard";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
@@ -19,26 +23,85 @@ export const PriceFeeds = async ({ params }: Props) => {
   if (parsedCluster === undefined) {
     notFound();
   }
+
   const feeds = await getPriceFeeds(parsedCluster, key);
   const metricsTime = feeds.find((feed) => feed.ranking !== undefined)?.ranking
     ?.time;
 
   return (
     <PriceFeedsCard
-      label="Price Feeds"
-      searchPlaceholder="Feed symbol"
       metricsTime={metricsTime}
-      nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
       publisherKey={key}
       cluster={parsedCluster}
       priceFeeds={feeds.map(({ ranking, feed, status }) => ({
         symbol: feed.symbol,
+        name: (
+          <PriceFeedTag
+            displaySymbol={feed.product.display_symbol}
+            description={feed.product.description}
+            icon={
+              <PriceFeedIcon
+                assetClass={feed.product.asset_type}
+                symbol={feed.symbol}
+              />
+            }
+          />
+        ),
         score: ranking?.final_score,
+        rank: ranking?.final_rank,
         uptimeScore: ranking?.uptime_score,
         deviationScore: ranking?.deviation_score,
         stalledScore: ranking?.stalled_score,
         status,
+        feedKey: feed.product.price_account,
+        nameAsString: feed.product.display_symbol,
+        id: feed.product.price_account,
+        assetClass: feed.product.asset_type,
+        displaySymbol: feed.product.display_symbol,
+        firstEvaluation: ranking?.first_ranking_time,
       }))}
     />
   );
 };
+
+export const PriceFeedsLoading = () => <PriceFeedsCard isLoading />;
+
+type PriceFeedsCardProps =
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      publisherKey: string;
+      cluster: Cluster;
+      priceFeeds: Omit<PriceComponent, "cluster" | "publisherKey">[];
+      metricsTime?: Date | undefined;
+    };
+
+const PriceFeedsCard = (props: PriceFeedsCardProps) => (
+  <PriceComponentsCard
+    label="Price Feeds"
+    searchPlaceholder="Feed symbol"
+    nameLoadingSkeleton={<PriceFeedTag isLoading />}
+    extraColumns={[
+      {
+        id: "assetClassBadge",
+        name: "ASSET CLASS",
+        alignment: "left",
+        allowsSorting: true,
+      },
+    ]}
+    nameWidth={90}
+    {...(props.isLoading
+      ? { isLoading: true }
+      : {
+          metricsTime: props.metricsTime,
+          priceComponents: props.priceFeeds.map((feed) => ({
+            ...feed,
+            cluster: props.cluster,
+            publisherKey: props.publisherKey,
+            assetClassBadge: (
+              <AssetClassBadge>{feed.assetClass}</AssetClassBadge>
+            ),
+          })),
+        })}
+  />
+);

+ 143 - 60
apps/insights/src/components/Publisher/top-feeds-table.tsx

@@ -2,76 +2,159 @@
 
 import type { RowConfig } from "@pythnetwork/component-library/Table";
 import { Table } from "@pythnetwork/component-library/Table";
+import type { ReactNode } from "react";
 import { useMemo } from "react";
 
-import { useSelectPriceFeed } from "./price-feed-drawer-provider";
 import styles from "./top-feeds-table.module.scss";
+import type { Cluster } from "../../services/pyth";
+import type { Status } from "../../status";
+import { AssetClassBadge } from "../AssetClassBadge";
 import { EntityList } from "../EntityList";
+import { usePriceComponentDrawer } from "../PriceComponentDrawer";
+import { PriceFeedTag } from "../PriceFeedTag";
+import { Score } from "../Score";
 
-type Props = {
-  publisherScoreWidth: number;
-  rows: (RowConfig<"score" | "asset" | "assetClass"> & { textValue: string })[];
-  label: string;
-};
+type Props =
+  | LoadingTopFeedsTableImplProps
+  | (ResolvedTopFeedsTableProps & { isLoading?: false | undefined });
 
-export const TopFeedsTable = ({ publisherScoreWidth, rows, label }: Props) => {
-  const selectPriceFeed = useSelectPriceFeed();
+export const TopFeedsTable = (props: Props) =>
+  props.isLoading ? (
+    <TopFeedsTableImpl {...props} />
+  ) : (
+    <ResolvedTopFeedsTable {...props} />
+  );
 
-  const rowsWithAction = useMemo(
+type ResolvedTopFeedsTableProps = BaseTopFeedsTableImplProps & {
+  publisherKey: string;
+  cluster: Cluster;
+  feeds: {
+    key: string;
+    symbol: string;
+    displaySymbol: string;
+    description: string;
+    assetClass: string;
+    score: number;
+    rank: number;
+    status: Status;
+    firstEvaluation: Date;
+    icon: ReactNode;
+    href: string;
+  }[];
+};
+
+const ResolvedTopFeedsTable = ({
+  cluster,
+  feeds,
+  publisherKey,
+  ...props
+}: ResolvedTopFeedsTableProps) => {
+  const drawerComponents = useMemo(
     () =>
-      rows.map((row) => ({
-        ...row,
-        ...(selectPriceFeed && {
-          onAction: () => {
-            selectPriceFeed(row.id.toString());
-          },
-        }),
+      feeds.map((feed) => ({
+        name: (
+          <PriceFeedTag
+            displaySymbol={feed.displaySymbol}
+            description={feed.description}
+            icon={feed.icon}
+          />
+        ),
+        publisherKey,
+        feedKey: feed.key,
+        cluster,
+        ...feed,
       })),
-    [selectPriceFeed, rows],
+    [feeds, cluster, publisherKey],
   );
 
-  return (
-    <>
-      <EntityList
-        label={label}
-        className={styles.list ?? ""}
-        fields={[
-          { id: "score", name: "Score" },
-          { id: "assetClass", name: "Asset Class" },
-        ]}
-        rows={rowsWithAction.map((row) => ({
-          ...row,
-          textValue: row.textValue,
-          header: row.data.asset,
-        }))}
-      />
-      <Table
-        label={label}
-        rounded
-        fill
-        className={styles.table ?? ""}
-        columns={[
-          {
-            id: "score",
-            name: "SCORE",
-            alignment: "left",
-            width: publisherScoreWidth,
-          },
-          {
-            id: "asset",
-            name: "ASSET",
-            isRowHeader: true,
-            alignment: "left",
-          },
-          {
-            id: "assetClass",
-            name: "ASSET CLASS",
-            alignment: "right",
-            width: 40,
-          },
-        ]}
-        rows={rowsWithAction}
-      />
-    </>
+  const { selectComponent } = usePriceComponentDrawer({
+    components: drawerComponents,
+  });
+
+  const rows = useMemo(
+    () =>
+      drawerComponents.map((feed) => ({
+        id: feed.symbol,
+        textValue: feed.symbol,
+        header: feed.name,
+        data: {
+          asset: feed.name,
+          assetClass: <AssetClassBadge>{feed.assetClass}</AssetClassBadge>,
+          score: <Score width={props.publisherScoreWidth} score={feed.score} />,
+        },
+        onAction: () => {
+          selectComponent(feed);
+        },
+      })),
+    [drawerComponents, props.publisherScoreWidth, selectComponent],
   );
+
+  return <TopFeedsTableImpl rows={rows} {...props} />;
+};
+
+type BaseTopFeedsTableImplProps = {
+  publisherScoreWidth: number;
+  label: string;
+  nameLoadingSkeleton: ReactNode;
+};
+type LoadingTopFeedsTableImplProps = BaseTopFeedsTableImplProps & {
+  isLoading: true;
 };
+type LoadedTopFeedsTableImplProps = BaseTopFeedsTableImplProps & {
+  isLoading?: false | undefined;
+  rows: (RowConfig<"score" | "asset" | "assetClass"> & {
+    textValue: string;
+    header: ReactNode;
+  })[];
+};
+type TopFeedsTableImplProps =
+  | LoadingTopFeedsTableImplProps
+  | LoadedTopFeedsTableImplProps;
+
+const TopFeedsTableImpl = ({
+  publisherScoreWidth,
+  label,
+  nameLoadingSkeleton,
+  ...props
+}: TopFeedsTableImplProps) => (
+  <>
+    <EntityList
+      label={label}
+      className={styles.list ?? ""}
+      headerLoadingSkeleton={nameLoadingSkeleton}
+      fields={[
+        { id: "score", name: "Score" },
+        { id: "assetClass", name: "Asset Class" },
+      ]}
+      {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
+    />
+    <Table
+      label={label}
+      rounded
+      fill
+      className={styles.table ?? ""}
+      columns={[
+        {
+          id: "score",
+          name: "SCORE",
+          alignment: "left",
+          width: publisherScoreWidth,
+        },
+        {
+          id: "asset",
+          name: "ASSET",
+          isRowHeader: true,
+          alignment: "left",
+          loadingSkeleton: nameLoadingSkeleton,
+        },
+        {
+          id: "assetClass",
+          name: "ASSET CLASS",
+          alignment: "right",
+          width: 40,
+        },
+      ]}
+      {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
+    />
+  </>
+);

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

@@ -142,6 +142,7 @@ const ResolvedPublishersCard = ({
           id,
           href: `/publishers/${cluster}/${id}`,
           textValue: publisher.name ?? id,
+          prefetch: false,
           data: {
             ranking: <Ranking>{ranking}</Ranking>,
             name: (
@@ -158,6 +159,7 @@ const ResolvedPublishersCard = ({
               <Link
                 href={`/publishers/${cluster}/${id}/price-feeds?status=Active`}
                 invert
+                prefetch={false}
               >
                 {activeFeeds}
               </Link>

+ 1 - 5
apps/insights/src/components/Root/footer.tsx

@@ -1,6 +1,5 @@
 import type { Props as ButtonProps } from "@pythnetwork/component-library/Button";
 import { Button } from "@pythnetwork/component-library/Button";
-import { DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Link } from "@pythnetwork/component-library/Link";
 import type { ComponentProps, ElementType } from "react";
 
@@ -19,10 +18,7 @@ export const Footer = () => (
         </Link>
         <div className={styles.divider} />
         <div className={styles.help}>
-          <DrawerTrigger>
-            <Link>Help</Link>
-            <SupportDrawer />
-          </DrawerTrigger>
+          <Link drawer={SupportDrawer}>Help</Link>
           <Link href="https://docs.pyth.network" target="_blank">
             Documentation
           </Link>

+ 10 - 13
apps/insights/src/components/Root/header.tsx

@@ -1,6 +1,5 @@
 import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
 import { Button } from "@pythnetwork/component-library/Button";
-import { DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Link } from "@pythnetwork/component-library/Link";
 import clsx from "clsx";
 import type { ComponentProps } from "react";
@@ -31,18 +30,16 @@ export const Header = ({ className, tabs, ...props }: Props) => (
         <MainNavTabs className={styles.mainNavTabs ?? ""} items={tabs} />
       </div>
       <div className={styles.rightMenu}>
-        <DrawerTrigger>
-          <Button
-            beforeIcon={Lifebuoy}
-            variant="ghost"
-            size="sm"
-            rounded
-            className={styles.supportButton ?? ""}
-          >
-            Support
-          </Button>
-          <SupportDrawer />
-        </DrawerTrigger>
+        <Button
+          variant="ghost"
+          size="sm"
+          rounded
+          beforeIcon={Lifebuoy}
+          drawer={SupportDrawer}
+          className={styles.supportButton ?? ""}
+        >
+          Support
+        </Button>
         <SearchButton
           className={styles.outlineSearchButton ?? ""}
           variant="outline"

+ 37 - 43
apps/insights/src/components/Root/index.tsx

@@ -7,7 +7,6 @@ import { Footer } from "./footer";
 import { Header } from "./header";
 import styles from "./index.module.scss";
 import { MobileNavTabs } from "./mobile-nav-tabs";
-import { SearchDialogProvider } from "./search-dialog";
 import { TabRoot, TabPanel } from "./tabs";
 import {
   ENABLE_ACCESSIBILITY_REPORTING,
@@ -15,11 +14,11 @@ import {
   AMPLITUDE_API_KEY,
 } from "../../config/server";
 import { LivePriceDataProvider } from "../../hooks/use-live-price-data";
-import { PriceFeedsProvider as PriceFeedsProviderImpl } from "../../hooks/use-price-feeds";
 import { getPublishers } from "../../services/clickhouse";
 import { Cluster, getFeeds } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
+import { SearchButtonProvider as SearchButtonProviderImpl } from "./search-button";
 
 export const TABS = [
   { href: "/", id: "", children: "Overview" },
@@ -35,21 +34,16 @@ type Props = {
   children: ReactNode;
 };
 
-export const Root = async ({ children }: Props) => {
-  const publishers = await Promise.all([
-    getPublishersForSearchDialog(Cluster.Pythnet),
-    getPublishersForSearchDialog(Cluster.PythtestConformance),
-  ]);
-
+export const Root = ({ children }: Props) => {
   return (
     <BaseRoot
       amplitudeApiKey={AMPLITUDE_API_KEY}
       googleAnalyticsId={GOOGLE_ANALYTICS_ID}
       enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
-      providers={[NuqsAdapter, LivePriceDataProvider, PriceFeedsProvider]}
+      providers={[NuqsAdapter, LivePriceDataProvider]}
       className={styles.root}
     >
-      <SearchDialogProvider publishers={publishers.flat()}>
+      <SearchButtonProvider>
         <TabRoot className={styles.tabRoot ?? ""}>
           <Header className={styles.header} tabs={TABS} />
           <main className={styles.main}>
@@ -58,12 +52,29 @@ export const Root = async ({ children }: Props) => {
           <Footer />
           <MobileNavTabs tabs={TABS} className={styles.mobileNavTabs} />
         </TabRoot>
-      </SearchDialogProvider>
+      </SearchButtonProvider>
     </BaseRoot>
   );
 };
 
+const SearchButtonProvider = async ({ children }: { children: ReactNode }) => {
+  const [publishers, feeds] = await Promise.all([
+    Promise.all([
+      getPublishersForSearchDialog(Cluster.Pythnet),
+      getPublishersForSearchDialog(Cluster.PythtestConformance),
+    ]),
+    getFeedsForSearchDialog(Cluster.Pythnet),
+  ]);
+
+  return (
+    <SearchButtonProviderImpl publishers={publishers.flat()} feeds={feeds}>
+      {children}
+    </SearchButtonProviderImpl>
+  );
+};
+
 const getPublishersForSearchDialog = async (cluster: Cluster) => {
+  "use cache";
   const publishers = await getPublishers(cluster);
   return publishers.map((publisher) => {
     const knownPublisher = lookupPublisher(publisher.key);
@@ -80,37 +91,20 @@ const getPublishersForSearchDialog = async (cluster: Cluster) => {
   });
 };
 
-const PriceFeedsProvider = async ({ children }: { children: ReactNode }) => {
-  const [pythnetFeeds, pythtestConformanceFeeds] = await Promise.all([
-    getFeeds(Cluster.Pythnet),
-    getFeeds(Cluster.PythtestConformance),
-  ]);
+const getFeedsForSearchDialog = async (cluster: Cluster) => {
+  "use cache";
+  const feeds = await getFeeds(cluster);
 
-  const feedMap = new Map(
-    pythnetFeeds.map((feed) => [
-      feed.symbol,
-      {
-        displaySymbol: feed.product.display_symbol,
-        icon: (
-          <PriceFeedIcon
-            assetClass={feed.product.asset_type}
-            symbol={feed.product.display_symbol}
-          />
-        ),
-        description: feed.product.description,
-        key: {
-          [Cluster.Pythnet]: feed.product.price_account,
-          [Cluster.PythtestConformance]:
-            pythtestConformanceFeeds.find(
-              (conformanceFeed) => conformanceFeed.symbol === feed.symbol,
-            )?.product.price_account ?? "",
-        },
-        assetClass: feed.product.asset_type,
-      },
-    ]),
-  );
-
-  return (
-    <PriceFeedsProviderImpl value={feedMap}>{children}</PriceFeedsProviderImpl>
-  );
+  return feeds.map((feed) => ({
+    symbol: feed.symbol,
+    displaySymbol: feed.product.display_symbol,
+    assetClass: feed.product.asset_type,
+    description: feed.product.description,
+    icon: (
+      <PriceFeedIcon
+        assetClass={feed.product.asset_type}
+        symbol={feed.symbol}
+      />
+    ),
+  }));
 };

+ 44 - 65
apps/insights/src/components/Root/mobile-menu.tsx

@@ -1,10 +1,6 @@
-"use client";
-
 import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
 import { List } from "@phosphor-icons/react/dist/ssr/List";
 import { Button } from "@pythnetwork/component-library/Button";
-import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
-import { useCallback, useState, useRef } from "react";
 
 import styles from "./mobile-menu.module.scss";
 import { SupportDrawer } from "./support-drawer";
@@ -14,65 +10,48 @@ type Props = {
   className?: string | undefined;
 };
 
-export const MobileMenu = ({ className }: Props) => {
-  const [isSupportDrawerOpen, setSupportDrawerOpen] = useState(false);
-  const openSupportDrawerOnClose = useRef(false);
-  const setOpenSupportDrawerOnClose = useCallback(() => {
-    openSupportDrawerOnClose.current = true;
-  }, []);
-  const maybeOpenSupportDrawer = useCallback(() => {
-    if (openSupportDrawerOnClose.current) {
-      setSupportDrawerOpen(true);
-      openSupportDrawerOnClose.current = false;
-    }
-  }, [setSupportDrawerOpen]);
+export const MobileMenu = ({ className }: Props) => (
+  <Button
+    className={className ?? ""}
+    beforeIcon={List}
+    variant="ghost"
+    size="sm"
+    rounded
+    hideText
+    drawer={{
+      hideHeading: true,
+      title: "Menu",
+      contents: <MobileMenuContents />,
+    }}
+  >
+    Menu
+  </Button>
+);
 
-  return (
-    <>
-      <DrawerTrigger>
-        <Button
-          className={className ?? ""}
-          beforeIcon={List}
-          variant="ghost"
-          size="sm"
-          rounded
-          hideText
-        >
-          Menu
-        </Button>
-        <Drawer hideHeading title="Menu" onCloseFinish={maybeOpenSupportDrawer}>
-          <div className={styles.mobileMenu}>
-            <div className={styles.buttons}>
-              <Button
-                slot="close"
-                beforeIcon={Lifebuoy}
-                variant="ghost"
-                size="md"
-                rounded
-                onPress={setOpenSupportDrawerOnClose}
-              >
-                Support
-              </Button>
-              <Button
-                href="https://docs.pyth.network"
-                size="md"
-                rounded
-                target="_blank"
-              >
-                Dev Docs
-              </Button>
-            </div>
-            <div className={styles.theme}>
-              <span className={styles.themeLabel}>Theme</span>
-              <ThemeSwitch />
-            </div>
-          </div>
-        </Drawer>
-      </DrawerTrigger>
-      <SupportDrawer
-        isOpen={isSupportDrawerOpen}
-        onOpenChange={setSupportDrawerOpen}
-      />
-    </>
-  );
-};
+const MobileMenuContents = () => (
+  <div className={styles.mobileMenu}>
+    <div className={styles.buttons}>
+      <Button
+        variant="ghost"
+        size="md"
+        rounded
+        beforeIcon={Lifebuoy}
+        drawer={SupportDrawer}
+      >
+        Support
+      </Button>
+      <Button
+        href="https://docs.pyth.network"
+        size="md"
+        rounded
+        target="_blank"
+      >
+        Dev Docs
+      </Button>
+    </div>
+    <div className={styles.theme}>
+      <span className={styles.themeLabel}>Theme</span>
+      <ThemeSwitch />
+    </div>
+  </div>
+);

+ 1 - 22
apps/insights/src/components/Root/search-dialog.module.scss → apps/insights/src/components/Root/search-button.module.scss

@@ -1,26 +1,5 @@
 @use "@pythnetwork/component-library/theme";
 
-.modalOverlay {
-  position: fixed;
-  inset: 0;
-  background: rgba(from black r g b / 30%);
-  z-index: 1;
-
-  .searchMenu {
-    position: relative;
-    top: theme.spacing(32);
-    margin: 0 auto;
-    outline: none;
-    background: theme.color("background", "secondary");
-    border-radius: theme.border-radius("2xl");
-    padding: theme.spacing(1);
-    max-height: theme.spacing(120);
-    width: min-content;
-    overflow: hidden;
-    display: flex;
-  }
-}
-
 .searchDialogContents {
   gap: theme.spacing(1);
   display: flex;
@@ -101,7 +80,7 @@
 
     .listbox {
       outline: none;
-      overflow: auto;
+      overflow-y: scroll;
       flex-grow: 1;
 
       .item {

+ 379 - 15
apps/insights/src/components/Root/search-button.tsx

@@ -1,28 +1,140 @@
 "use client";
 
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
+import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import { useLogger } from "@pythnetwork/app-logger";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import type { Props as ButtonProps } from "@pythnetwork/component-library/Button";
 import { Button } from "@pythnetwork/component-library/Button";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import type { ComponentProps } from "react";
-import { useMemo } from "react";
-import { useIsSSR } from "react-aria";
+import {
+  Virtualizer,
+  ListLayout,
+} from "@pythnetwork/component-library/Virtualizer";
+import type { Button as UnstyledButton } from "@pythnetwork/component-library/unstyled/Button";
+import {
+  ListBox,
+  ListBoxItem,
+} from "@pythnetwork/component-library/unstyled/ListBox";
+import { useDrawer } from "@pythnetwork/component-library/useDrawer";
+import type { ReactNode, ComponentProps } from "react";
+import {
+  useMemo,
+  useCallback,
+  useEffect,
+  useState,
+  createContext,
+  use,
+} from "react";
+import { useIsSSR, useCollator, useFilter } from "react-aria";
 
-import { useToggleSearchDialog } from "./search-dialog";
+import styles from "./search-button.module.scss";
+import { Cluster, ClusterToName } from "../../services/pyth";
+import { AssetClassBadge } from "../AssetClassBadge";
+import { NoResults } from "../NoResults";
+import { PriceFeedTag } from "../PriceFeedTag";
+import { PublisherTag } from "../PublisherTag";
+import { Score } from "../Score";
 
-type Props = ComponentProps<typeof Button>;
+const INPUTS = new Set(["input", "select", "button", "textarea"]);
 
-export const SearchButton = (props: Props) => {
-  const toggleSearchDialog = useToggleSearchDialog();
+const SearchButtonContext = createContext<undefined | (() => void)>(undefined);
 
-  return (
-    <Button
-      onPress={toggleSearchDialog}
-      beforeIcon={MagnifyingGlass}
-      size="sm"
-      rounded
-      {...props}
-    />
+type Props = Omit<ComponentProps<typeof SearchButtonContext>, "value"> & {
+  feeds: {
+    symbol: string;
+    displaySymbol: string;
+    assetClass: string;
+    description: string;
+    icon: ReactNode;
+  }[];
+  publishers: ({
+    publisherKey: string;
+    averageScore: number;
+    cluster: Cluster;
+  } & (
+    | { name: string; icon: ReactNode }
+    | { name?: undefined; icon?: undefined }
+  ))[];
+};
+
+export const SearchButtonProvider = ({
+  feeds,
+  publishers,
+  ...props
+}: Props) => {
+  const drawer = useDrawer();
+
+  const searchDrawer = useMemo(
+    () => ({
+      fill: true,
+      hideHeading: true,
+      title: "Search",
+      variant: "dialog" as const,
+      contents: <SearchDialogContents feeds={feeds} publishers={publishers} />,
+    }),
+    [feeds, publishers],
   );
+
+  const openSearchDrawer = useCallback(() => {
+    drawer.open(searchDrawer);
+  }, [drawer, searchDrawer]);
+
+  const handleKeyDown = useCallback(
+    (event: KeyboardEvent) => {
+      const activeElement = document.activeElement;
+      const tagName = activeElement?.tagName.toLowerCase();
+      const isEditing =
+        !tagName ||
+        INPUTS.has(tagName) ||
+        (activeElement !== null &&
+          "isContentEditable" in activeElement &&
+          activeElement.isContentEditable);
+      const isSlash = event.key === "/";
+      // Meta key for mac, ctrl key for non-mac
+      const isCtrlK = event.key === "k" && (event.metaKey || event.ctrlKey);
+
+      if (!isEditing && (isSlash || isCtrlK)) {
+        event.preventDefault();
+        openSearchDrawer();
+      }
+    },
+    [openSearchDrawer],
+  );
+
+  useEffect(() => {
+    globalThis.addEventListener("keydown", handleKeyDown);
+    return () => {
+      globalThis.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [handleKeyDown]);
+
+  return <SearchButtonContext value={openSearchDrawer} {...props} />;
+};
+
+export const SearchButton = (
+  props: Omit<
+    ButtonProps<typeof UnstyledButton>,
+    "beforeIcon" | "size" | "rounded" | "onPress"
+  >,
+) => {
+  const openSearchDrawer = use(SearchButtonContext);
+  if (openSearchDrawer) {
+    return (
+      <Button
+        className={styles.outlineSearchButton ?? ""}
+        beforeIcon={MagnifyingGlass}
+        size="sm"
+        rounded
+        onPress={openSearchDrawer}
+        {...props}
+      />
+    );
+  } else {
+    throw new Error("Search drawer context not initialized!");
+  }
 };
 
 export const SearchShortcutText = () => {
@@ -34,3 +146,255 @@ const SearchTextImpl = () => {
   const isMac = useMemo(() => navigator.userAgent.includes("Mac"), []);
   return isMac ? "⌘ K" : "Ctrl K";
 };
+
+type SearchDialogContentsProps = {
+  feeds: Props["feeds"];
+  publishers: Props["publishers"];
+};
+
+const SearchDialogContents = ({
+  feeds,
+  publishers,
+}: SearchDialogContentsProps) => {
+  const drawer = useDrawer();
+  const logger = useLogger();
+  const [search, setSearch] = useState("");
+  const [type, setType] = useState<ResultType | "">("");
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const closeDrawer = useCallback(() => {
+    drawer.close().catch((error: unknown) => {
+      logger.error(error);
+    });
+  }, [drawer, logger]);
+  const results = useMemo(
+    () =>
+      [
+        ...(type === ResultType.Publisher
+          ? []
+          : // This is inefficient but Safari doesn't support `Iterator.filter`,
+            // see https://bugs.webkit.org/show_bug.cgi?id=248650
+            [...feeds.entries()]
+              .filter(([, { displaySymbol }]) =>
+                filter.contains(displaySymbol, search),
+              )
+              .map(([symbol, feed]) => ({
+                type: ResultType.PriceFeed as const,
+                id: symbol,
+                ...feed,
+              }))),
+        ...(type === ResultType.PriceFeed
+          ? []
+          : publishers
+              .filter(
+                (publisher) =>
+                  filter.contains(publisher.publisherKey, search) ||
+                  (publisher.name && filter.contains(publisher.name, search)),
+              )
+              .map((publisher) => ({
+                type: ResultType.Publisher as const,
+                id: [
+                  ClusterToName[publisher.cluster],
+                  publisher.publisherKey,
+                ].join(":"),
+                ...publisher,
+              }))),
+      ].sort((a, b) =>
+        collator.compare(
+          a.type === ResultType.PriceFeed
+            ? a.displaySymbol
+            : (a.name ?? a.publisherKey),
+          b.type === ResultType.PriceFeed
+            ? b.displaySymbol
+            : (b.name ?? b.publisherKey),
+        ),
+      ),
+    [feeds, publishers, collator, filter, search, type],
+  );
+
+  return (
+    <div className={styles.searchDialogContents}>
+      <div className={styles.searchBar}>
+        <div className={styles.left}>
+          <SearchInput
+            size="md"
+            placeholder="Asset symbol, publisher name or id"
+            value={search}
+            onChange={setSearch}
+            className={styles.searchInput ?? ""}
+            // eslint-disable-next-line jsx-a11y/no-autofocus
+            autoFocus
+          />
+          <SingleToggleGroup
+            selectedKey={type}
+            className={styles.typeFilter ?? ""}
+            // @ts-expect-error react-aria coerces everything to Key for some reason...
+            onSelectionChange={setType}
+            items={[
+              { id: "", children: "All" },
+              { id: ResultType.PriceFeed, children: "Price Feeds" },
+              { id: ResultType.Publisher, children: "Publishers" },
+            ]}
+          />
+        </div>
+        <Button
+          className={styles.closeButton ?? ""}
+          beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+          slot="close"
+          hideText
+          rounded
+          variant="ghost"
+          size="sm"
+        >
+          Close
+        </Button>
+      </div>
+      <div className={styles.body}>
+        <Virtualizer layout={new ListLayout()}>
+          <ListBox
+            aria-label="Search"
+            items={results}
+            className={styles.listbox ?? ""}
+            // eslint-disable-next-line jsx-a11y/no-autofocus
+            autoFocus={false}
+            // @ts-expect-error looks like react-aria isn't exposing this
+            // property in the typescript types correctly...
+            shouldFocusOnHover
+            emptyState={
+              <NoResults
+                query={search}
+                onClearSearch={() => {
+                  setSearch("");
+                }}
+              />
+            }
+          >
+            {(result) => (
+              <ListBoxItem
+                textValue={
+                  result.type === ResultType.PriceFeed
+                    ? result.displaySymbol
+                    : (result.name ?? result.publisherKey)
+                }
+                className={styles.item ?? ""}
+                onAction={closeDrawer}
+                href={
+                  result.type === ResultType.PriceFeed
+                    ? `/price-feeds/${encodeURIComponent(result.symbol)}`
+                    : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
+                }
+                data-is-first={result.id === results[0]?.id ? "" : undefined}
+              >
+                <div className={styles.smallScreen}>
+                  {result.type === ResultType.PriceFeed ? (
+                    <PriceFeedTag
+                      className={styles.itemTag}
+                      displaySymbol={result.displaySymbol}
+                      description={result.description}
+                      icon={result.icon}
+                    />
+                  ) : (
+                    <PublisherTag
+                      className={styles.itemTag}
+                      cluster={result.cluster}
+                      publisherKey={result.publisherKey}
+                      {...(result.name && {
+                        name: result.name,
+                        icon: result.icon,
+                      })}
+                    />
+                  )}
+                  <dl className={styles.bottom}>
+                    <div className={styles.field}>
+                      <dt>Type</dt>
+                      <dd>
+                        <Badge
+                          variant={
+                            result.type === ResultType.PriceFeed
+                              ? "warning"
+                              : "info"
+                          }
+                          style="filled"
+                          size="xs"
+                        >
+                          {result.type === ResultType.PriceFeed
+                            ? "PRICE FEED"
+                            : "PUBLISHER"}
+                        </Badge>
+                      </dd>
+                    </div>
+                    <div className={styles.field}>
+                      {result.type === ResultType.PriceFeed ? (
+                        <>
+                          <dt>Asset Class</dt>
+                          <dd>
+                            <AssetClassBadge>
+                              {result.assetClass}
+                            </AssetClassBadge>
+                          </dd>
+                        </>
+                      ) : (
+                        <>
+                          <dt>Average Score</dt>
+                          <dd>
+                            <Score score={result.averageScore} />
+                          </dd>
+                        </>
+                      )}
+                    </div>
+                  </dl>
+                </div>
+                <div className={styles.largeScreen}>
+                  <div className={styles.itemType}>
+                    <Badge
+                      variant={
+                        result.type === ResultType.PriceFeed
+                          ? "warning"
+                          : "info"
+                      }
+                      style="filled"
+                      size="xs"
+                    >
+                      {result.type === ResultType.PriceFeed
+                        ? "PRICE FEED"
+                        : "PUBLISHER"}
+                    </Badge>
+                  </div>
+                  {result.type === ResultType.PriceFeed ? (
+                    <>
+                      <PriceFeedTag
+                        displaySymbol={result.displaySymbol}
+                        description={result.description}
+                        icon={result.icon}
+                        className={styles.itemTag}
+                      />
+                      <AssetClassBadge>{result.assetClass}</AssetClassBadge>
+                    </>
+                  ) : (
+                    <>
+                      <PublisherTag
+                        className={styles.itemTag}
+                        cluster={result.cluster}
+                        publisherKey={result.publisherKey}
+                        {...(result.name && {
+                          name: result.name,
+                          icon: result.icon,
+                        })}
+                      />
+                      <Score score={result.averageScore} />
+                    </>
+                  )}
+                </div>
+              </ListBoxItem>
+            )}
+          </ListBox>
+        </Virtualizer>
+      </div>
+    </div>
+  );
+};
+
+enum ResultType {
+  PriceFeed,
+  Publisher,
+}

+ 0 - 468
apps/insights/src/components/Root/search-dialog.tsx

@@ -1,468 +0,0 @@
-"use client";
-
-import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
-import { Badge } from "@pythnetwork/component-library/Badge";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Drawer } from "@pythnetwork/component-library/Drawer";
-import { ModalDialog } from "@pythnetwork/component-library/ModalDialog";
-import { SearchInput } from "@pythnetwork/component-library/SearchInput";
-import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
-import {
-  Virtualizer,
-  ListLayout,
-} from "@pythnetwork/component-library/Virtualizer";
-import {
-  ListBox,
-  ListBoxItem,
-} from "@pythnetwork/component-library/unstyled/ListBox";
-import { useMediaQuery } from "@react-hookz/web";
-import { useRouter } from "next/navigation";
-import type { ReactNode, ComponentProps } from "react";
-import {
-  useState,
-  useCallback,
-  useEffect,
-  createContext,
-  use,
-  useMemo,
-} from "react";
-import { RouterProvider, useCollator, useFilter } from "react-aria";
-
-import styles from "./search-dialog.module.scss";
-import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import { Cluster, ClusterToName } from "../../services/pyth";
-import { AssetClassTag } from "../AssetClassTag";
-import { NoResults } from "../NoResults";
-import { PriceFeedTag } from "../PriceFeedTag";
-import { PublisherTag } from "../PublisherTag";
-import { Score } from "../Score";
-
-const CLOSE_DURATION_IN_SECONDS = 0.1;
-const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_SECONDS * 1000;
-
-const INPUTS = new Set(["input", "select", "button", "textarea"]);
-
-const SearchDialogOpenContext = createContext<
-  ReturnType<typeof useSearchDialogStateContext> | undefined
->(undefined);
-
-type Props = {
-  children: ReactNode;
-  publishers: ({
-    publisherKey: string;
-    averageScore: number;
-    cluster: Cluster;
-  } & (
-    | { name: string; icon: ReactNode }
-    | { name?: undefined; icon?: undefined }
-  ))[];
-};
-
-export const SearchDialogProvider = ({ children, publishers }: Props) => {
-  const searchDialogState = useSearchDialogStateContext();
-  const [search, setSearch] = useState("");
-  const [type, setType] = useState<ResultType | "">("");
-  const collator = useCollator();
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const feeds = usePriceFeeds();
-
-  const close = useCallback(
-    () =>
-      new Promise<void>((resolve) => {
-        searchDialogState.close();
-        setTimeout(() => {
-          setSearch("");
-          setType("");
-          resolve();
-        }, CLOSE_DURATION_IN_MS);
-      }),
-    [searchDialogState, setSearch, setType],
-  );
-
-  const handleOpenChange = useCallback(
-    (isOpen: boolean) => {
-      if (!isOpen) {
-        close().catch(() => {
-          /* no-op since this actually can't fail */
-        });
-      }
-    },
-    [close],
-  );
-
-  const router = useRouter();
-  const handleOpenItem = useCallback(
-    (href: string) => {
-      close()
-        .then(() => {
-          router.push(href);
-        })
-        .catch(() => {
-          /* no-op since this actually can't fail */
-        });
-    },
-    [close, router],
-  );
-
-  const results = useMemo(
-    () =>
-      [
-        ...(type === ResultType.Publisher
-          ? []
-          : // This is inefficient but Safari doesn't support `Iterator.filter`,
-            // see https://bugs.webkit.org/show_bug.cgi?id=248650
-            [...feeds.entries()]
-              .filter(([, { displaySymbol }]) =>
-                filter.contains(displaySymbol, search),
-              )
-              .map(([symbol, { assetClass, displaySymbol }]) => ({
-                type: ResultType.PriceFeed as const,
-                id: symbol,
-                assetClass,
-                displaySymbol,
-              }))),
-        ...(type === ResultType.PriceFeed
-          ? []
-          : publishers
-              .filter(
-                (publisher) =>
-                  filter.contains(publisher.publisherKey, search) ||
-                  (publisher.name && filter.contains(publisher.name, search)),
-              )
-              .map((publisher) => ({
-                type: ResultType.Publisher as const,
-                id: [
-                  ClusterToName[publisher.cluster],
-                  publisher.publisherKey,
-                ].join(":"),
-                ...publisher,
-              }))),
-      ].sort((a, b) =>
-        collator.compare(
-          a.type === ResultType.PriceFeed
-            ? a.displaySymbol
-            : (a.name ?? a.publisherKey),
-          b.type === ResultType.PriceFeed
-            ? b.displaySymbol
-            : (b.name ?? b.publisherKey),
-        ),
-      ),
-    [feeds, publishers, collator, filter, search, type],
-  );
-
-  return (
-    <>
-      <SearchDialogOpenContext value={searchDialogState}>
-        {children}
-      </SearchDialogOpenContext>
-      <SearchContainer
-        key="search-modal"
-        isOpen={searchDialogState.isOpen}
-        onOpenChange={handleOpenChange}
-        title="Search"
-      >
-        <div className={styles.searchDialogContents}>
-          <div className={styles.searchBar}>
-            <div className={styles.left}>
-              <SearchInput
-                size="md"
-                placeholder="Asset symbol, publisher name or id"
-                value={search}
-                onChange={setSearch}
-                className={styles.searchInput ?? ""}
-                // eslint-disable-next-line jsx-a11y/no-autofocus
-                autoFocus
-              />
-              <SingleToggleGroup
-                selectedKey={type}
-                className={styles.typeFilter ?? ""}
-                // @ts-expect-error react-aria coerces everything to Key for some reason...
-                onSelectionChange={setType}
-                items={[
-                  { id: "", children: "All" },
-                  { id: ResultType.PriceFeed, children: "Price Feeds" },
-                  { id: ResultType.Publisher, children: "Publishers" },
-                ]}
-              />
-            </div>
-            <Button
-              className={styles.closeButton ?? ""}
-              beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-              slot="close"
-              hideText
-              rounded
-              variant="ghost"
-              size="sm"
-            >
-              Close
-            </Button>
-          </div>
-          <div className={styles.body}>
-            <RouterProvider navigate={handleOpenItem}>
-              <Virtualizer layout={new ListLayout()}>
-                <ListBox
-                  aria-label="Search"
-                  items={results}
-                  className={styles.listbox ?? ""}
-                  // eslint-disable-next-line jsx-a11y/no-autofocus
-                  autoFocus={false}
-                  // @ts-expect-error looks like react-aria isn't exposing this
-                  // property in the typescript types correctly...
-                  shouldFocusOnHover
-                  emptyState={
-                    <NoResults
-                      query={search}
-                      onClearSearch={() => {
-                        setSearch("");
-                      }}
-                    />
-                  }
-                >
-                  {(result) => (
-                    <ListBoxItem
-                      textValue={
-                        result.type === ResultType.PriceFeed
-                          ? result.displaySymbol
-                          : (result.name ?? result.publisherKey)
-                      }
-                      className={styles.item ?? ""}
-                      href={
-                        result.type === ResultType.PriceFeed
-                          ? `/price-feeds/${encodeURIComponent(result.id)}`
-                          : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
-                      }
-                      data-is-first={
-                        result.id === results[0]?.id ? "" : undefined
-                      }
-                    >
-                      <div className={styles.smallScreen}>
-                        {result.type === ResultType.PriceFeed ? (
-                          <PriceFeedTag
-                            compact
-                            symbol={result.id}
-                            className={styles.itemTag}
-                          />
-                        ) : (
-                          <PublisherTag
-                            className={styles.itemTag}
-                            compact
-                            cluster={result.cluster}
-                            publisherKey={result.publisherKey}
-                            {...(result.name && {
-                              name: result.name,
-                              icon: result.icon,
-                            })}
-                          />
-                        )}
-                        <dl className={styles.bottom}>
-                          <div className={styles.field}>
-                            <dt>Type</dt>
-                            <dd>
-                              <Badge
-                                variant={
-                                  result.type === ResultType.PriceFeed
-                                    ? "warning"
-                                    : "info"
-                                }
-                                style="filled"
-                                size="xs"
-                              >
-                                {result.type === ResultType.PriceFeed
-                                  ? "PRICE FEED"
-                                  : "PUBLISHER"}
-                              </Badge>
-                            </dd>
-                          </div>
-                          <div className={styles.field}>
-                            {result.type === ResultType.PriceFeed ? (
-                              <>
-                                <dt>Asset Class</dt>
-                                <dd>
-                                  <AssetClassTag
-                                    symbol={result.id}
-                                    className={styles.itemExtra ?? ""}
-                                  />
-                                </dd>
-                              </>
-                            ) : (
-                              <>
-                                <dt>Average Score</dt>
-                                <dd>
-                                  <Score
-                                    score={result.averageScore}
-                                    className={styles.itemExtra ?? ""}
-                                  />
-                                </dd>
-                              </>
-                            )}
-                          </div>
-                        </dl>
-                      </div>
-                      <div className={styles.largeScreen}>
-                        <div className={styles.itemType}>
-                          <Badge
-                            variant={
-                              result.type === ResultType.PriceFeed
-                                ? "warning"
-                                : "info"
-                            }
-                            style="filled"
-                            size="xs"
-                          >
-                            {result.type === ResultType.PriceFeed
-                              ? "PRICE FEED"
-                              : "PUBLISHER"}
-                          </Badge>
-                        </div>
-                        {result.type === ResultType.PriceFeed ? (
-                          <>
-                            <PriceFeedTag
-                              compact
-                              symbol={result.id}
-                              className={styles.itemTag}
-                            />
-                            <AssetClassTag
-                              symbol={result.id}
-                              className={styles.itemExtra ?? ""}
-                            />
-                          </>
-                        ) : (
-                          <>
-                            <PublisherTag
-                              className={styles.itemTag}
-                              compact
-                              cluster={result.cluster}
-                              publisherKey={result.publisherKey}
-                              {...(result.name && {
-                                name: result.name,
-                                icon: result.icon,
-                              })}
-                            />
-                            <Score
-                              score={result.averageScore}
-                              className={styles.itemExtra ?? ""}
-                            />
-                          </>
-                        )}
-                      </div>
-                    </ListBoxItem>
-                  )}
-                </ListBox>
-              </Virtualizer>
-            </RouterProvider>
-          </div>
-        </div>
-      </SearchContainer>
-    </>
-  );
-};
-
-const SearchContainer = (
-  props: ComponentProps<typeof Drawer> & { title: string },
-) => {
-  const isLarge = useMediaQuery(
-    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
-  );
-
-  return isLarge ? (
-    <ModalDialog
-      overlayVariants={{
-        unmounted: { backgroundColor: "#00000000" },
-        hidden: { backgroundColor: "#00000000" },
-        visible: { backgroundColor: "#00000080" },
-      }}
-      overlayClassName={styles.modalOverlay ?? ""}
-      className={styles.searchMenu ?? ""}
-      variants={{
-        visible: {
-          y: 0,
-          transition: { type: "spring", duration: 0.8, bounce: 0.35 },
-        },
-        hidden: {
-          y: "calc(-100% - 8rem)",
-          transition: { ease: "linear", duration: CLOSE_DURATION_IN_SECONDS },
-        },
-        unmounted: {
-          y: "calc(-100% - 8rem)",
-        },
-      }}
-      aria-label={props.title}
-      {...props}
-    />
-  ) : (
-    <Drawer fill hideHeading {...props} />
-  );
-};
-
-enum ResultType {
-  PriceFeed,
-  Publisher,
-}
-
-const useSearchDialogStateContext = () => {
-  const [isOpen, setIsOpen] = useState(false);
-  const toggleIsOpen = useCallback(() => {
-    setIsOpen((value) => !value);
-  }, [setIsOpen]);
-  const close = useCallback(() => {
-    setIsOpen(false);
-  }, [setIsOpen]);
-  const open = useCallback(() => {
-    setIsOpen(true);
-  }, [setIsOpen]);
-
-  const handleKeyDown = useCallback(
-    (event: KeyboardEvent) => {
-      const activeElement = document.activeElement;
-      const tagName = activeElement?.tagName.toLowerCase();
-      const isEditing =
-        !tagName ||
-        INPUTS.has(tagName) ||
-        (activeElement !== null &&
-          "isContentEditable" in activeElement &&
-          activeElement.isContentEditable);
-      const isSlash = event.key === "/";
-      // Meta key for mac, ctrl key for non-mac
-      const isCtrlK = event.key === "k" && (event.metaKey || event.ctrlKey);
-
-      if (!isEditing && (isSlash || isCtrlK)) {
-        event.preventDefault();
-        toggleIsOpen();
-      }
-    },
-    [toggleIsOpen],
-  );
-
-  useEffect(() => {
-    globalThis.addEventListener("keydown", handleKeyDown);
-    return () => {
-      globalThis.removeEventListener("keydown", handleKeyDown);
-    };
-  }, [handleKeyDown]);
-
-  return {
-    isOpen,
-    setIsOpen,
-    toggleIsOpen,
-    open,
-    close,
-  };
-};
-
-const useSearchDialogState = () => {
-  const value = use(SearchDialogOpenContext);
-  if (value) {
-    return value;
-  } else {
-    throw new NotInitializedError();
-  }
-};
-
-export const useToggleSearchDialog = () => useSearchDialogState().toggleIsOpen;
-
-class NotInitializedError extends Error {
-  constructor() {
-    super("This component must be contained within a <SearchDialogProvider>");
-    this.name = "NotInitializedError";
-  }
-}

+ 77 - 74
apps/insights/src/components/Root/support-drawer.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import { BookOpenText } from "@phosphor-icons/react/dist/ssr/BookOpenText";
 import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
 import { Code } from "@phosphor-icons/react/dist/ssr/Code";
@@ -7,85 +9,12 @@ import { Plug } from "@phosphor-icons/react/dist/ssr/Plug";
 import { ShieldChevron } from "@phosphor-icons/react/dist/ssr/ShieldChevron";
 import type { Props as CardProps } from "@pythnetwork/component-library/Card";
 import { Card } from "@pythnetwork/component-library/Card";
-import { Drawer } from "@pythnetwork/component-library/Drawer";
 import type { Link as UnstyledLink } from "@pythnetwork/component-library/unstyled/Link";
-import type { ComponentProps, ReactNode } from "react";
+import type { ReactNode } from "react";
 
 import { socialLinks } from "./social-links";
 import styles from "./support-drawer.module.scss";
 
-export const SupportDrawer = (
-  props: Omit<ComponentProps<typeof Drawer>, "title" | "bodyClassName">,
-) => (
-  <Drawer title="Support" bodyClassName={styles.supportDrawer} {...props}>
-    <LinkList
-      title="Integration"
-      links={[
-        {
-          icon: <Plug />,
-          title: "Connect directly with real-time market data",
-          description: "Integrate the Pyth data feeds into your app",
-          target: "_blank",
-          href: "https://docs.pyth.network/price-feeds/use-real-time-data",
-        },
-        {
-          icon: <BookOpenText />,
-          title: "Learn how to work with Pyth data",
-          description: "Read the Pyth Network documentation",
-          target: "_blank",
-          href: "https://docs.pyth.network",
-        },
-        {
-          icon: <Code />,
-          title: "Try out the APIs",
-          description:
-            "Use the Pyth Network API Reference to experience the Pyth APIs",
-          target: "_blank",
-          href: "https://api-reference.pyth.network",
-        },
-      ]}
-    />
-    <LinkList
-      title="$PYTH Token"
-      links={[
-        {
-          icon: <Coins />,
-          title: "Tokenomics",
-          description:
-            "Learn about how the $PYTH token is structured and distributed",
-          target: "_blank",
-          href: "https://docs.pyth.network/home/pyth-token/pyth-distribution",
-        },
-        {
-          icon: <ShieldChevron />,
-          title: "Oracle Integrity Staking (OIS) Guide",
-          description: "Learn how to help secure the oracle and earn rewards",
-          target: "_blank",
-          href: "https://docs.pyth.network/home/oracle-integrity-staking",
-        },
-        {
-          icon: <Gavel />,
-          title: "Pyth Governance Guide",
-          description:
-            "Gain voting power to help shape the future of DeFi by participating in governance",
-          target: "_blank",
-          href: "https://docs.pyth.network/home/pyth-token#staking-pyth-for-governance",
-        },
-      ]}
-    />
-    <LinkList
-      title="Community"
-      links={socialLinks.map(({ icon: Icon, href, name }) => ({
-        href,
-        target: "_blank",
-        title: name,
-        description: href,
-        icon: <Icon />,
-      }))}
-    />
-  </Drawer>
-);
-
 type LinkListProps = {
   title: ReactNode;
   links: (Omit<
@@ -115,3 +44,77 @@ const LinkList = ({ title, links }: LinkListProps) => (
     </ul>
   </div>
 );
+
+export const SupportDrawer = {
+  title: "Support",
+  bodyClassName: styles.supportDrawer,
+  contents: (
+    <>
+      <LinkList
+        title="Integration"
+        links={[
+          {
+            icon: <Plug />,
+            title: "Connect directly with real-time market data",
+            description: "Integrate the Pyth data feeds into your app",
+            target: "_blank",
+            href: "https://docs.pyth.network/price-feeds/use-real-time-data",
+          },
+          {
+            icon: <BookOpenText />,
+            title: "Learn how to work with Pyth data",
+            description: "Read the Pyth Network documentation",
+            target: "_blank",
+            href: "https://docs.pyth.network",
+          },
+          {
+            icon: <Code />,
+            title: "Try out the APIs",
+            description:
+              "Use the Pyth Network API Reference to experience the Pyth APIs",
+            target: "_blank",
+            href: "https://api-reference.pyth.network",
+          },
+        ]}
+      />
+      <LinkList
+        title="$PYTH Token"
+        links={[
+          {
+            icon: <Coins />,
+            title: "Tokenomics",
+            description:
+              "Learn about how the $PYTH token is structured and distributed",
+            target: "_blank",
+            href: "https://docs.pyth.network/home/pyth-token/pyth-distribution",
+          },
+          {
+            icon: <ShieldChevron />,
+            title: "Oracle Integrity Staking (OIS) Guide",
+            description: "Learn how to help secure the oracle and earn rewards",
+            target: "_blank",
+            href: "https://docs.pyth.network/home/oracle-integrity-staking",
+          },
+          {
+            icon: <Gavel />,
+            title: "Pyth Governance Guide",
+            description:
+              "Gain voting power to help shape the future of DeFi by participating in governance",
+            target: "_blank",
+            href: "https://docs.pyth.network/home/pyth-token#staking-pyth-for-governance",
+          },
+        ]}
+      />
+      <LinkList
+        title="Community"
+        links={socialLinks.map(({ icon: Icon, href, name }) => ({
+          href,
+          target: "_blank",
+          title: name,
+          description: href,
+          icon: <Icon />,
+        }))}
+      />
+    </>
+  ),
+};

+ 4 - 43
apps/insights/src/components/Root/tabs.tsx

@@ -8,9 +8,6 @@ import {
 import { useSelectedLayoutSegment, usePathname } from "next/navigation";
 import type { ComponentProps } from "react";
 
-import type { VariantArg } from "../LayoutTransition";
-import { LayoutTransition } from "../LayoutTransition";
-
 export const TabRoot = (
   props: Omit<ComponentProps<typeof Tabs>, "selectedKey">,
 ) => {
@@ -27,46 +24,10 @@ export const MainNavTabs = (
   return <MainNavTabsComponent pathname={pathname} {...props} />;
 };
 
-export const TabPanel = ({
-  children,
-  ...props
-}: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">) => {
+export const TabPanel = (
+  props: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">,
+) => {
   const tabId = useSelectedLayoutSegment() ?? "";
 
-  return (
-    <UnstyledTabPanel key="tabpanel" id={tabId} {...props}>
-      {(args) => (
-        <LayoutTransition
-          variants={{
-            initial: (custom) => ({
-              opacity: 0,
-              x: isMovingLeft(custom) ? "-2%" : "2%",
-            }),
-            exit: (custom) => ({
-              opacity: 0,
-              x: isMovingLeft(custom) ? "2%" : "-2%",
-              transition: {
-                x: { type: "spring", bounce: 0 },
-              },
-            }),
-          }}
-          initial="initial"
-          animate={{
-            opacity: 1,
-            x: 0,
-            transition: {
-              x: { type: "spring", bounce: 0 },
-            },
-          }}
-          exit="exit"
-        >
-          {typeof children === "function" ? children(args) : children}
-        </LayoutTransition>
-      )}
-    </UnstyledTabPanel>
-  );
+  return <UnstyledTabPanel key="tabpanel" id={tabId} {...props} />;
 };
-
-const isMovingLeft = ({ segment, prevSegment }: VariantArg): boolean =>
-  segment === null ||
-  (segment === "publishers" && prevSegment === "price-feeds");

+ 17 - 43
apps/insights/src/components/Tabs/index.tsx

@@ -9,8 +9,6 @@ import { useSelectedLayoutSegment, usePathname } from "next/navigation";
 import type { ComponentProps } from "react";
 import { useMemo } from "react";
 
-import { LayoutTransition } from "../LayoutTransition";
-
 export const TabRoot = (
   props: Omit<ComponentProps<typeof UnstyledTabs>, "selectedKey">,
 ) => {
@@ -20,7 +18,7 @@ export const TabRoot = (
 };
 
 type TabsProps = Omit<ComponentProps<typeof TabList>, "pathname" | "items"> & {
-  prefix: string;
+  prefix?: string;
   items: (Omit<
     ComponentProps<typeof TabList>["items"],
     "href" | "id"
@@ -31,55 +29,31 @@ type TabsProps = Omit<ComponentProps<typeof TabList>, "pathname" | "items"> & {
 
 export const Tabs = ({ prefix, items, ...props }: TabsProps) => {
   const pathname = usePathname();
+  const segment = useSelectedLayoutSegment();
+  const finalPrefix = useMemo(
+    () =>
+      (prefix ?? segment === null)
+        ? pathname
+        : pathname.replace(new RegExp(`/${segment}$`), ""),
+    [prefix, pathname, segment],
+  );
   const mappedItems = useMemo(
     () =>
       items.map((item) => ({
         ...item,
-        id: item.segment ?? "",
-        href: item.segment ? `${prefix}/${item.segment}` : prefix,
+        id: item.id ?? item.segment ?? "",
+        href: item.segment ? `${finalPrefix}/${item.segment}` : finalPrefix,
       })),
-    [items, prefix],
+    [items, finalPrefix],
   );
 
-  return <TabList pathname={pathname} items={mappedItems} {...props} />;
+  return <TabList currentTab={segment ?? ""} items={mappedItems} {...props} />;
 };
 
-export const TabPanel = ({
-  children,
-  ...props
-}: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">) => {
+export const TabPanel = (
+  props: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">,
+) => {
   const tabId = useSelectedLayoutSegment() ?? "";
 
-  return (
-    <UnstyledTabPanel key="tabpanel" id={tabId} {...props}>
-      {(args) => (
-        <LayoutTransition
-          variants={{
-            initial: ({ segment }) => ({
-              opacity: 0,
-              x: segment === null ? "-2%" : "2%",
-            }),
-            exit: ({ segment }) => ({
-              opacity: 0,
-              x: segment === null ? "2%" : "-2%",
-              transition: {
-                x: { type: "spring", bounce: 0 },
-              },
-            }),
-          }}
-          initial="initial"
-          animate={{
-            opacity: 1,
-            x: 0,
-            transition: {
-              x: { type: "spring", bounce: 0 },
-            },
-          }}
-          exit="exit"
-        >
-          {typeof children === "function" ? children(args) : children}
-        </LayoutTransition>
-      )}
-    </UnstyledTabPanel>
-  );
+  return <UnstyledTabPanel key="tabpanel" id={tabId} {...props} />;
 };

+ 0 - 60
apps/insights/src/components/ZoomLayoutTransition/index.tsx

@@ -1,60 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-
-import type { VariantArg } from "../LayoutTransition";
-import { LayoutTransition } from "../LayoutTransition";
-
-type Props = {
-  children: ReactNode;
-};
-
-export const ZoomLayoutTransition = ({ children }: Props) => (
-  <LayoutTransition
-    variants={{
-      initial: (custom) => ({
-        opacity: 0,
-        scale: getInitialScale(custom),
-      }),
-      exit: (custom) => ({
-        opacity: 0,
-        scale: getExitScale(custom),
-        transition: {
-          scale: { type: "spring", bounce: 0 },
-        },
-      }),
-    }}
-    initial="initial"
-    animate={{
-      opacity: 1,
-      scale: 1,
-      transition: {
-        scale: { type: "spring", bounce: 0 },
-      },
-    }}
-    style={{ transformOrigin: "top" }}
-    exit="exit"
-  >
-    {children}
-  </LayoutTransition>
-);
-
-const getInitialScale = ({ segment, prevSegment }: VariantArg) => {
-  if (segment === null) {
-    return 1.04;
-  } else if (prevSegment === null) {
-    return 0.96;
-  } else {
-    return 1;
-  }
-};
-
-const getExitScale = ({ segment, prevSegment }: VariantArg) => {
-  if (segment === null) {
-    return 0.96;
-  } else if (prevSegment === null) {
-    return 1.04;
-  } else {
-    return 1;
-  }
-};

+ 0 - 40
apps/insights/src/hooks/use-price-feeds.tsx

@@ -1,40 +0,0 @@
-"use client";
-
-import type { ReactNode, ComponentProps } from "react";
-import { createContext, use } from "react";
-
-import type { Cluster } from "../services/pyth";
-
-const PriceFeedsContext = createContext<undefined | PriceFeeds>(undefined);
-
-export const PriceFeedsProvider = (
-  props: ComponentProps<typeof PriceFeedsContext>,
-) => <PriceFeedsContext {...props} />;
-
-export const usePriceFeeds = () => {
-  const value = use(PriceFeedsContext);
-  if (value) {
-    return value;
-  } else {
-    throw new PriceFeedsNotInitializedError();
-  }
-};
-
-type PriceFeeds = Map<string, PriceFeed>;
-
-export type PriceFeed = {
-  displaySymbol: string;
-  icon: ReactNode;
-  description: string;
-  key: Record<Cluster, string>;
-  assetClass: string;
-};
-
-class PriceFeedsNotInitializedError extends Error {
-  constructor() {
-    super(
-      "This component must be a child of <PriceFeedsContext> to use the `usePriceFeeds` hook",
-    );
-    this.name = "PriceFeedsNotInitializedError";
-  }
-}

+ 5 - 10
apps/insights/src/services/pyth.ts

@@ -68,16 +68,11 @@ export const getPublishersForFeed = async (
 export const getFeeds = async (cluster: Cluster) => {
   const data = await clients[cluster].getData();
   return priceFeedsSchema.parse(
-    data.symbols
-      .filter(
-        (symbol) =>
-          data.productFromSymbol.get(symbol)?.display_symbol !== undefined,
-      )
-      .map((symbol) => ({
-        symbol,
-        product: data.productFromSymbol.get(symbol),
-        price: data.productPrice.get(symbol),
-      })),
+    data.symbols.map((symbol) => ({
+      symbol,
+      product: data.productFromSymbol.get(symbol),
+      price: data.productPrice.get(symbol),
+    })),
   );
 };
 

+ 3 - 0
apps/insights/turbo.json

@@ -29,6 +29,9 @@
       "dependsOn": ["//#install:modules"],
       "cache": false
     },
+    "start:prod": {
+      "dependsOn": ["//#install:modules", "build:vercel"]
+    },
     "test:lint": {
       "dependsOn": ["test:lint:eslint", "test:lint:stylelint"]
     },

+ 1 - 0
packages/component-library/.storybook/main.ts

@@ -34,6 +34,7 @@ const config = {
                   modules: {
                     auto: true,
                     localIdentName: "[name]__[local]--[hash:base64:5]",
+                    exportLocalsConvention: "as-is",
                   },
                   importLoaders: 1,
                   esModule: false,

+ 12 - 23
packages/component-library/.storybook/preview.tsx

@@ -2,46 +2,35 @@ import { sans } from "@pythnetwork/fonts";
 import { withThemeByClassName } from "@storybook/addon-themes";
 import type { Preview, Decorator } from "@storybook/react";
 import clsx from "clsx";
-import { useState } from "react";
 
 import "../src/Html/base.scss";
 import styles from "./storybook.module.scss";
-import { OverlayVisibleContext } from "../src/overlay-visible-context.js";
+import { MainContent } from "../src/MainContent";
 
 const preview = {
   parameters: {
+    layout: "fullscreen",
     backgrounds: {
-      grid: {
-        cellSize: 4,
-        cellAmount: 4,
-      },
-      options: {
-        primary: { name: "Primary", value: "var(--primary-background)" },
-        secondary: { name: "Secondary", value: "var(--secondary-background)" },
-      },
+      disable: true,
     },
     actions: { argTypesRegex: "^on[A-Z].*" },
   },
-  initialGlobals: {
-    backgrounds: { value: "primary" },
-  },
 } satisfies Preview;
 
 export default preview;
 
 export const decorators: Decorator[] = [
-  (Story) => {
-    const overlayVisibleState = useState(false);
-    return (
-      <OverlayVisibleContext value={overlayVisibleState}>
-        <Story />
-      </OverlayVisibleContext>
-    );
-  },
+  (Story) => (
+    <MainContent className={clsx(sans.className, styles.mainContent)}>
+      <Story />
+    </MainContent>
+  ),
   withThemeByClassName({
     themes: {
-      Light: clsx(sans.className, styles.light),
-      Dark: clsx(sans.className, styles.dark),
+      Light: styles.light ?? "",
+      "Light (Secondary Background)": clsx(styles.light, styles.secondary),
+      Dark: styles.dark ?? "",
+      "Dark (Secondary Background)": clsx(styles.dark, styles.secondary),
     },
     defaultTheme: "Light",
   }),

+ 8 - 5
packages/component-library/.storybook/storybook.module.scss

@@ -6,11 +6,6 @@ body {
   width: 100%;
 }
 
-:root {
-  --primary-background: #{theme.color("background", "primary")};
-  --secondary-background: #{theme.color("background", "secondary")};
-}
-
 .light {
   color-scheme: light;
 }
@@ -18,3 +13,11 @@ body {
 .dark {
   color-scheme: dark;
 }
+
+.secondary .mainContent {
+  background: theme.color("background", "secondary");
+}
+
+.mainContent {
+  padding: theme.spacing(10);
+}

+ 39 - 5
packages/component-library/src/Button/index.stories.tsx

@@ -60,11 +60,6 @@ const meta = {
         category: "Contents",
       },
     },
-    onPress: {
-      table: {
-        category: "Behavior",
-      },
-    },
     isPending: {
       control: "boolean",
       table: {
@@ -97,4 +92,43 @@ export const Button = {
     rounded: false,
     hideText: false,
   },
+  argTypes: {
+    onPress: {
+      table: {
+        category: "Behavior",
+      },
+    },
+  },
+} satisfies StoryObj<typeof ButtonComponent>;
+
+export const DrawerButton = {
+  args: {
+    children: "Open Drawer",
+    variant: "primary",
+    size: "md",
+    isDisabled: false,
+    isPending: false,
+    rounded: false,
+    hideText: false,
+    drawer: {
+      title: "Hello world",
+      contents: "This is a drawer",
+    },
+  },
+} satisfies StoryObj<typeof ButtonComponent>;
+
+export const AlertButton = {
+  args: {
+    children: "Open Alert",
+    variant: "primary",
+    size: "md",
+    isDisabled: false,
+    isPending: false,
+    rounded: false,
+    hideText: false,
+    alert: {
+      title: "Alert!",
+      contents: "This is an alert",
+    },
+  },
 } satisfies StoryObj<typeof ButtonComponent>;

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

@@ -42,7 +42,7 @@ export const Card = (
     return <div {...cardProps(otherProps)} />;
   } else if ("href" in props) {
     return <Link {...cardProps(props)} />;
-  } else if (overlayState !== null || "onPress" in props) {
+  } else if (overlayState !== null || "onPress" in props || "drawer" in props) {
     return <Button {...cardProps(props)} />;
   } else {
     return <div {...cardProps(props)} />;

+ 0 - 43
packages/component-library/src/Drawer/index.stories.tsx

@@ -1,43 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react";
-
-import { Drawer as DrawerComponent, DrawerTrigger } from "./index.js";
-import { Button } from "../Button/index.js";
-
-const meta = {
-  component: DrawerComponent,
-  decorators: [
-    (Story) => (
-      <DrawerTrigger>
-        <Button>Click me!</Button>
-        <Story />
-      </DrawerTrigger>
-    ),
-  ],
-  argTypes: {
-    title: {
-      control: "text",
-      table: {
-        category: "Contents",
-      },
-    },
-    children: {
-      control: "text",
-      table: {
-        category: "Contents",
-      },
-    },
-    closeHref: {
-      table: {
-        disable: true,
-      },
-    },
-  },
-} satisfies Meta<typeof DrawerComponent>;
-export default meta;
-
-export const Drawer = {
-  args: {
-    title: "A drawer",
-    children: "This is a drawer",
-  },
-} satisfies StoryObj<typeof DrawerComponent>;

+ 38 - 12
packages/component-library/src/Link/index.stories.tsx

@@ -11,18 +11,6 @@ const meta = {
         category: "Contents",
       },
     },
-    href: {
-      control: "text",
-      table: {
-        category: "Link",
-      },
-    },
-    target: {
-      control: "text",
-      table: {
-        category: "Link",
-      },
-    },
     isDisabled: {
       control: "boolean",
       table: {
@@ -47,4 +35,42 @@ export const Link = {
     isDisabled: false,
     invert: false,
   },
+  argTypes: {
+    href: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
+    target: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
+  },
+} satisfies StoryObj<typeof LinkComponent>;
+
+export const DrawerLink = {
+  args: {
+    children: "Open Drawer",
+    isDisabled: false,
+    invert: false,
+    drawer: {
+      title: "Hello world",
+      contents: "This is a drawer",
+    },
+  },
+} satisfies StoryObj<typeof LinkComponent>;
+
+export const AlertLink = {
+  args: {
+    children: "Open Alert",
+    isDisabled: false,
+    invert: false,
+    alert: {
+      title: "An alert",
+      contents: "This is an alert",
+    },
+  },
 } satisfies StoryObj<typeof LinkComponent>;

+ 4 - 1
packages/component-library/src/Link/index.tsx

@@ -8,7 +8,10 @@ import { Link as UnstyledLink } from "../unstyled/Link/index.js";
 type OwnProps = {
   invert?: boolean | undefined;
 };
-type Props<T extends ElementType> = Omit<ComponentProps<T>, keyof OwnProps> &
+export type Props<T extends ElementType> = Omit<
+  ComponentProps<T>,
+  keyof OwnProps
+> &
   OwnProps;
 
 export const Link = (

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

@@ -8,4 +8,5 @@
   overflow: hidden auto;
   transform: scale(calc(100% - (var(--offset) * 5%)));
   height: 100dvh;
+  scrollbar-gutter: stable;
 }

+ 15 - 40
packages/component-library/src/MainContent/index.tsx

@@ -1,55 +1,30 @@
 "use client";
 
 import clsx from "clsx";
-import type {
-  ComponentProps,
-  CSSProperties,
-  Dispatch,
-  SetStateAction,
-} from "react";
-import { createContext, useState, use } from "react";
+import type { ComponentProps, CSSProperties } from "react";
+import { useState } from "react";
 
 import styles from "./index.module.scss";
 import { OverlayVisibleContext } from "../overlay-visible-context.js";
-
-const MainContentOffsetContext = createContext<
-  undefined | [number, Dispatch<SetStateAction<number>>]
->(undefined);
+import { AlertProvider } from "../useAlert/index.js";
+import { DrawerProvider } from "../useDrawer/index.js";
 
 export const MainContent = ({ className, ...props }: ComponentProps<"div">) => {
   const overlayVisibleState = useState(false);
-  const offset = useState(0);
+  const [offset, setOffset] = useState(0);
 
   return (
     <OverlayVisibleContext value={overlayVisibleState}>
-      <MainContentOffsetContext value={offset}>
-        <div
-          className={clsx(styles.mainContent, className)}
-          style={
-            {
-              "--offset": offset[0] / 100,
-            } as CSSProperties
-          }
-          data-overlay-visible={overlayVisibleState[0] ? "" : undefined}
-          {...props}
-        />
-      </MainContentOffsetContext>
+      <AlertProvider>
+        <DrawerProvider setMainContentOffset={setOffset}>
+          <div
+            className={clsx(styles.mainContent, className)}
+            style={{ "--offset": offset / 100 } as CSSProperties}
+            data-overlay-visible={overlayVisibleState[0] ? "" : undefined}
+            {...props}
+          />
+        </DrawerProvider>
+      </AlertProvider>
     </OverlayVisibleContext>
   );
 };
-
-export const useMainContentOffset = () => {
-  const value = use(MainContentOffsetContext);
-  if (value === undefined) {
-    throw new MainContentNotInitializedError();
-  } else {
-    return value;
-  }
-};
-
-class MainContentNotInitializedError extends Error {
-  constructor() {
-    super("This component must be contained within a <MainContent>");
-    this.name = "MainContentNotInitializedError";
-  }
-}

+ 130 - 2
packages/component-library/src/ModalDialog/index.tsx

@@ -2,8 +2,21 @@
 
 import type { PanInfo } from "motion/react";
 import { motion } from "motion/react";
-import type { ComponentProps, Dispatch, SetStateAction } from "react";
-import { createContext, use, useCallback, useState, useEffect } from "react";
+import type {
+  ComponentProps,
+  ComponentType,
+  Dispatch,
+  ReactNode,
+  SetStateAction,
+} from "react";
+import {
+  createContext,
+  use,
+  useCallback,
+  useState,
+  useEffect,
+  useRef,
+} from "react";
 import type { ModalRenderProps } from "react-aria-components";
 import {
   Modal,
@@ -72,6 +85,7 @@ type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> &
     overlayVariants?:
       | ComponentProps<typeof MotionModalOverlay>["variants"]
       | undefined;
+    onClose?: (() => void) | undefined;
     onCloseFinish?: (() => void) | undefined;
     onDragEnd?: (
       e: MouseEvent | TouchEvent | PointerEvent,
@@ -86,6 +100,7 @@ type Props = Omit<ComponentProps<typeof MotionDialog>, keyof OwnProps> &
 export const ModalDialog = ({
   isOpen,
   onOpenChange,
+  onClose,
   onCloseFinish,
   overlayClassName,
   overlayVariants,
@@ -102,6 +117,8 @@ export const ModalDialog = ({
   const startAnimation = (animation: AnimationState) => {
     if (animation === "visible") {
       showOverlay();
+    } else if (animation === "hidden") {
+      onClose?.();
     }
   };
 
@@ -159,3 +176,114 @@ export const ModalDialog = ({
 };
 
 type AnimationState = "unmounted" | "hidden" | "visible";
+
+export const createModalDialogContext = <
+  T extends Props,
+  U = Record<never, never>,
+>(
+  Component: ComponentType<T>,
+) => {
+  type ContextType = {
+    close: () => Promise<void>;
+    open: (modalDialogProps: OpenArgs<T, U>) => void;
+  };
+
+  const Context = createContext<ContextType | undefined>(undefined);
+
+  return {
+    Provider: ({ children, ...ctxProps }: U & { children: ReactNode }) => {
+      const promiseCloseResolvers = useRef<(() => void)[]>([]);
+      const [isOpen, setIsOpen] = useState(false);
+      const [currentModalDialog, setModalDialog] = useState<
+        OpenArgs<T, U> | undefined
+      >(undefined);
+      const close = useCallback(() => {
+        setIsOpen(false);
+        return new Promise<void>((resolve) => {
+          promiseCloseResolvers.current.push(resolve);
+        });
+      }, []);
+      const open = useCallback(
+        (props: OpenArgs<T, U>) => {
+          if (currentModalDialog && currentModalDialog !== props) {
+            close()
+              .then(() => {
+                setTimeout(() => {
+                  setModalDialog(props);
+                  setIsOpen(true);
+                });
+              })
+              .catch((error: unknown) => {
+                throw error;
+              });
+          } else if (!currentModalDialog) {
+            setModalDialog(props);
+            setIsOpen(true);
+          }
+        },
+        [currentModalDialog, setModalDialog, close],
+      );
+      const handleOpenChange = useCallback(
+        (newValue: boolean) => {
+          if (!newValue) {
+            setIsOpen(false);
+          }
+        },
+        [setIsOpen],
+      );
+      const handleCloseFinish = useCallback(() => {
+        const onCloseFinished = currentModalDialog?.onCloseFinished;
+        setModalDialog(undefined);
+        onCloseFinished?.();
+        for (const resolver of promiseCloseResolvers.current) {
+          resolver();
+        }
+        promiseCloseResolvers.current = [];
+      }, [setModalDialog, currentModalDialog]);
+
+      return (
+        <Context value={{ open, close }}>
+          {children}
+          {currentModalDialog !== undefined && (
+            // @ts-expect-error TODO typescript isn't validating this type
+            // properly.  To be honest, I'm not sure why, but the code for
+            // `createModalDialogContext` is pretty messy and I think
+            // simplifying this would probably resolve the issue.  I'll come
+            // back and refactor this eventually and see if this goes away...
+            <Component
+              isOpen={isOpen}
+              onOpenChange={handleOpenChange}
+              onCloseFinish={handleCloseFinish}
+              {...ctxProps}
+              {...currentModalDialog}
+            />
+          )}
+        </Context>
+      );
+    },
+
+    useValue: () => {
+      const value = use(Context);
+      if (value === undefined) {
+        throw new ContextNotInitializedError();
+      } else {
+        return value;
+      }
+    },
+  };
+};
+
+export type OpenArgs<T, U = undefined> = Omit<
+  T,
+  "isOpen" | "onOpenChange" | "onCloseFinish" | keyof U
+> & {
+  onClose?: (() => void) | undefined;
+  onCloseFinished?: (() => void) | undefined;
+};
+
+class ContextNotInitializedError extends Error {
+  constructor() {
+    super("This component must be contained within a provider");
+    this.name = "ContextNotInitializedError";
+  }
+}

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

@@ -4,6 +4,7 @@
   display: flex;
   flex-flow: row nowrap;
   justify-content: center;
+  align-items: center;
 
   @include theme.breakpoint("sm") {
     justify-content: space-between;

+ 1 - 1
packages/component-library/src/Select/index.stories.tsx

@@ -4,7 +4,7 @@ import { Select as SelectComponent } from "./index.js";
 import buttonMeta from "../Button/index.stories.js";
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-const { children, beforeIcon, onPress, ...argTypes } = buttonMeta.argTypes;
+const { children, beforeIcon, ...argTypes } = buttonMeta.argTypes;
 const meta = {
   component: SelectComponent,
   argTypes: {

+ 2 - 0
packages/component-library/src/Spinner/index.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import clsx from "clsx";
 import type { ComponentProps } from "react";
 import { ProgressBar } from "react-aria-components";

+ 1 - 1
packages/component-library/src/TabList/index.stories.tsx

@@ -11,7 +11,7 @@ const meta = {
         disable: true,
       },
     },
-    pathname: {
+    currentTab: {
       table: {
         disable: true,
       },

+ 4 - 4
packages/component-library/src/TabList/index.tsx

@@ -10,17 +10,17 @@ import { Tab, TabList as UnstyledTabList } from "../unstyled/Tabs/index.js";
 
 type OwnProps = {
   label: string;
-  pathname?: string | undefined;
+  currentTab?: string | undefined;
   items: ComponentProps<typeof Tab>[];
 };
 type Props = Omit<ComponentProps<typeof UnstyledTabList>, keyof OwnProps> &
   OwnProps;
 
-export const TabList = ({ label, className, pathname, ...props }: Props) => (
+export const TabList = ({ label, className, currentTab, ...props }: Props) => (
   <div className={clsx(styles.tabs, className)}>
     <UnstyledTabList
       aria-label={label}
-      dependencies={[pathname]}
+      dependencies={[currentTab]}
       className={styles.tabList ?? ""}
       {...props}
     >
@@ -29,7 +29,7 @@ export const TabList = ({ label, className, pathname, ...props }: Props) => (
           className={clsx(styles.tab, buttonStyles.button, tabClassName)}
           data-size="sm"
           data-variant="ghost"
-          data-selectable={pathname === tab.href ? undefined : ""}
+          data-selectable={currentTab === tab.id ? undefined : ""}
           {...tab}
         >
           {(args) => (

+ 58 - 1
packages/component-library/src/unstyled/Button/index.tsx

@@ -1,3 +1,60 @@
 "use client";
 
-export { Button } from "react-aria-components";
+import type { ComponentProps } from "react";
+import { useCallback } from "react";
+import type { PressEvent } from "react-aria-components";
+import { Button as BaseButton } from "react-aria-components";
+
+import type { OpenAlertArgs } from "../../useAlert/index.js";
+import { useAlert } from "../../useAlert/index.js";
+import type { OpenDrawerArgs } from "../../useDrawer/index.js";
+import { useDrawer } from "../../useDrawer/index.js";
+
+export type Props = ComponentProps<typeof BaseButton> & {
+  alert?: OpenAlertArgs | undefined;
+  drawer?: OpenDrawerArgs | undefined;
+};
+
+export const Button = ({ drawer, alert, ...props }: Props) => {
+  if (drawer !== undefined) {
+    return <DrawerButton {...props} drawer={drawer} />;
+  } else if (alert === undefined) {
+    return <BaseButton {...props} />;
+  } else {
+    return <AlertButton {...props} alert={alert} />;
+  }
+};
+
+const DrawerButton = ({
+  onPress,
+  drawer: drawerConfig,
+  ...props
+}: Props & { drawer: OpenDrawerArgs }) => {
+  const drawer = useDrawer();
+  const openDrawer = useCallback(
+    (event: PressEvent) => {
+      onPress?.(event);
+      drawer.open(drawerConfig);
+    },
+    [drawer, drawerConfig, onPress],
+  );
+
+  return <BaseButton {...props} onPress={openDrawer} />;
+};
+
+const AlertButton = ({
+  onPress,
+  alert: alertConfig,
+  ...props
+}: Props & { alert: OpenAlertArgs }) => {
+  const alert = useAlert();
+  const openDrawer = useCallback(
+    (event: PressEvent) => {
+      onPress?.(event);
+      alert.open(alertConfig);
+    },
+    [alert, alertConfig, onPress],
+  );
+
+  return <BaseButton {...props} onPress={openDrawer} />;
+};

+ 1 - 1
packages/component-library/src/unstyled/Link/index.tsx

@@ -5,7 +5,7 @@ import { Link as BaseLink } from "react-aria-components";
 
 import { usePrefetch } from "../../use-prefetch.js";
 
-type Props = ComponentProps<typeof BaseLink> & {
+export type Props = ComponentProps<typeof BaseLink> & {
   prefetch?: Parameters<typeof usePrefetch>[0]["prefetch"];
 };
 

+ 0 - 0
packages/component-library/src/Alert/index.module.scss → packages/component-library/src/useAlert/index.module.scss


+ 22 - 13
packages/component-library/src/Alert/index.stories.tsx → packages/component-library/src/useAlert/index.stories.tsx

@@ -1,19 +1,27 @@
 import * as Icon from "@phosphor-icons/react/dist/ssr";
 import type { Meta, StoryObj } from "@storybook/react";
 
-import { Alert as AlertComponent, AlertTrigger } from "./index.js";
+import { useAlert as useAlertImpl } from "./index.js";
 import { Button } from "../Button/index.js";
 
+const ShowButton = (
+  props: Parameters<ReturnType<typeof useAlertImpl>["open"]>[0],
+) => {
+  const drawer = useAlertImpl();
+  return (
+    <Button
+      onPress={() => {
+        drawer.open(props);
+      }}
+    >
+      Show alert
+    </Button>
+  );
+};
+
 const meta = {
-  component: AlertComponent,
-  decorators: [
-    (Story) => (
-      <AlertTrigger>
-        <Button>Click me!</Button>
-        <Story />
-      </AlertTrigger>
-    ),
-  ],
+  title: "hooks/useAlert",
+  component: ShowButton,
   argTypes: {
     icon: {
       control: "select",
@@ -41,13 +49,14 @@ const meta = {
       },
     },
   },
-} satisfies Meta<typeof AlertComponent>;
+} satisfies Meta<typeof ShowButton>;
 export default meta;
 
-export const Alert = {
+export const useAlert = {
+  name: "useAlert",
   args: {
     title: "An Alert",
     children:
       "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
   },
-} satisfies StoryObj<typeof AlertComponent>;
+} satisfies StoryObj<typeof ShowButton>;

+ 28 - 29
packages/component-library/src/Alert/index.tsx → packages/component-library/src/useAlert/index.tsx

@@ -7,17 +7,16 @@ import { Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
-import { ModalDialog } from "../ModalDialog/index.js";
-
-export { ModalDialogTrigger as AlertTrigger } from "../ModalDialog/index.js";
+import type { OpenArgs } from "../ModalDialog/index.js";
+import { ModalDialog, createModalDialogContext } from "../ModalDialog/index.js";
 
 const CLOSE_DURATION_IN_S = 0.1;
-export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 
-type OwnProps = Pick<ComponentProps<typeof ModalDialog>, "children"> & {
+type OwnProps = {
   icon?: ReactNode | undefined;
   title: ReactNode;
   bodyClassName?: string | undefined;
+  contents: ReactNode;
 };
 
 type Props = Omit<
@@ -26,10 +25,10 @@ type Props = Omit<
 > &
   OwnProps;
 
-export const Alert = ({
+const Alert = ({
   icon,
   title,
-  children,
+  contents,
   className,
   bodyClassName,
   ...props
@@ -50,27 +49,27 @@ export const Alert = ({
     className={clsx(styles.alert, className)}
     {...props}
   >
-    {(...args) => (
-      <>
-        <Button
-          className={styles.closeButton ?? ""}
-          beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-          slot="close"
-          hideText
-          rounded
-          variant="ghost"
-          size="sm"
-        >
-          Close
-        </Button>
-        <Heading className={styles.title} slot="title">
-          {icon && <div className={styles.icon}>{icon}</div>}
-          <div>{title}</div>
-        </Heading>
-        <div className={clsx(styles.body, bodyClassName)}>
-          {typeof children === "function" ? children(...args) : children}
-        </div>
-      </>
-    )}
+    <Button
+      className={styles.closeButton ?? ""}
+      beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+      slot="close"
+      hideText
+      rounded
+      variant="ghost"
+      size="sm"
+    >
+      Close
+    </Button>
+    <Heading className={styles.title} slot="title">
+      {icon && <div className={styles.icon}>{icon}</div>}
+      <div>{title}</div>
+    </Heading>
+    <div className={clsx(styles.body, bodyClassName)}>{contents}</div>
   </ModalDialog>
 );
+
+const { Provider, useValue } = createModalDialogContext<Props>(Alert);
+
+export const AlertProvider = Provider;
+export const useAlert = useValue;
+export type OpenAlertArgs = OpenArgs<Props>;

+ 29 - 17
packages/component-library/src/Drawer/index.module.scss → packages/component-library/src/useDrawer/index.module.scss

@@ -26,15 +26,32 @@
     overflow-y: hidden;
 
     @include theme.breakpoint("sm") {
-      top: theme.spacing(4);
-      bottom: theme.spacing(4);
-      left: unset;
-      right: theme.spacing(4);
-      width: 60%;
-      max-width: theme.spacing(180);
-      max-height: unset;
-      border-radius: theme.border-radius("3xl");
-      padding-bottom: theme.border-radius("3xl");
+      &[data-variant="dialog"] {
+        position: relative;
+        top: theme.spacing(32);
+        left: unset;
+        right: unset;
+        bottom: unset;
+        margin: 0 auto;
+        background: theme.color("background", "secondary");
+        border: unset;
+        border-radius: theme.border-radius("2xl");
+        padding: theme.spacing(1);
+        max-height: theme.spacing(120);
+        width: max-content;
+      }
+
+      &[data-variant="default"] {
+        top: theme.spacing(4);
+        bottom: theme.spacing(4);
+        left: unset;
+        right: theme.spacing(4);
+        width: 60%;
+        max-width: theme.spacing(180);
+        max-height: unset;
+        border-radius: theme.border-radius("3xl");
+        padding-bottom: theme.border-radius("3xl");
+      }
     }
 
     .handle {
@@ -111,6 +128,7 @@
       flex: 1;
       overflow-y: auto;
       padding: theme.spacing(4);
+      grid-auto-rows: minmax(0, max-content);
 
       @include theme.breakpoint("sm") {
         padding: theme.spacing(6);
@@ -136,14 +154,8 @@
       }
     }
 
-    &[data-hide-heading] {
-      .heading {
-        display: none;
-
-        @include theme.breakpoint("sm") {
-          display: flex;
-        }
-      }
+    &[data-hide-heading] .heading {
+      @include theme.sr-only;
     }
   }
 }

+ 52 - 0
packages/component-library/src/useDrawer/index.stories.tsx

@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { useDrawer as useDrawerImpl } from "./index.js";
+import { Button } from "../Button/index.js";
+
+const OpenButton = (
+  props: Parameters<ReturnType<typeof useDrawerImpl>["open"]>[0],
+) => {
+  const drawer = useDrawerImpl();
+  return (
+    <Button
+      onPress={() => {
+        drawer.open(props);
+      }}
+    >
+      Open drawer
+    </Button>
+  );
+};
+
+const meta = {
+  title: "hooks/useDrawer",
+  component: OpenButton,
+  argTypes: {
+    title: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    contents: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    onClose: {
+      table: {
+        category: "Behavior",
+      },
+    },
+  },
+} satisfies Meta<typeof OpenButton>;
+export default meta;
+
+export const useDrawer = {
+  name: "useDrawer",
+  args: {
+    title: "A drawer",
+    contents: "This is a drawer",
+  },
+} satisfies StoryObj<typeof OpenButton>;

+ 146 - 85
packages/component-library/src/Drawer/index.tsx → packages/component-library/src/useDrawer/index.tsx

@@ -1,5 +1,3 @@
-"use client";
-
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import { useMediaQuery } from "@react-hookz/web";
 import clsx from "clsx";
@@ -10,13 +8,10 @@ import { Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
-import { useMainContentOffset } from "../MainContent/index.js";
-import { ModalDialog } from "../ModalDialog/index.js";
-
-export { ModalDialogTrigger as DrawerTrigger } from "../ModalDialog/index.js";
+import type { OpenArgs } from "../ModalDialog/index.js";
+import { ModalDialog, createModalDialogContext } from "../ModalDialog/index.js";
 
 const CLOSE_DURATION_IN_S = 0.15;
-export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 
 type OwnProps = {
   fill?: boolean | undefined;
@@ -29,19 +24,26 @@ type OwnProps = {
   bodyClassName?: string | undefined;
   footerClassName?: string | undefined;
   hideHeading?: boolean | undefined;
+  setMainContentOffset: (value: number) => void;
+  contents: ReactNode | undefined;
+  variant?: "default" | "dialog" | undefined;
 };
 
 type Props = Omit<
   ComponentProps<typeof ModalDialog>,
-  keyof OwnProps | "overlayVariants" | "overlayClassName" | "variants"
+  | keyof OwnProps
+  | "overlayVariants"
+  | "overlayClassName"
+  | "variants"
+  | "children"
 > &
   OwnProps;
 
-export const Drawer = ({
+const Drawer = ({
   className,
   title,
   closeHref,
-  children,
+  contents,
   fill,
   footer,
   headingClassName,
@@ -50,95 +52,30 @@ export const Drawer = ({
   headingExtra,
   headingAfter,
   hideHeading,
+  setMainContentOffset,
+  variant = "default",
   ...props
 }: Props) => {
-  const [, setMainContentOffset] = useMainContentOffset();
-  const modalRef = useRef<null | HTMLDivElement>(null);
-  const [isDragging, setIsDragging] = useState(false);
   const [isHandlePressed, setIsHandlePressed] = useState(false);
-  const isLarge = useMediaQuery(
-    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
+  const { isDragging, props: animationProps } = useAnimationProps(
+    variant,
+    setMainContentOffset,
   );
-  const y = useMotionValue("100%");
-
-  useMotionValueEvent(y, "change", (y) => {
-    if (typeof y === "string") {
-      setMainContentOffset(100 - Number.parseInt(y.replace(/%$/, ""), 10));
-    } else if (modalRef.current) {
-      setMainContentOffset(100 - (100 * y) / modalRef.current.offsetHeight);
-    }
-  });
 
   return (
     <ModalDialog
-      ref={modalRef}
+      data-variant={variant}
       overlayVariants={{
         unmounted: { backgroundColor: "#00000000" },
         hidden: { backgroundColor: "#00000000" },
         visible: { backgroundColor: "#00000080" },
       }}
       overlayClassName={styles.modalOverlay ?? ""}
-      variants={
-        isLarge
-          ? {
-              visible: {
-                x: 0,
-                transition: { type: "spring", duration: 1, bounce: 0.35 },
-              },
-              hidden: {
-                x: "calc(100% + 1rem)",
-                transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-              },
-              unmounted: {
-                x: "calc(100% + 1rem)",
-              },
-            }
-          : {
-              visible: {
-                y: 0,
-                transition: {
-                  duration: 0.5,
-                  ease: [0.32, 0.72, 0, 1],
-                },
-              },
-              hidden: {
-                y: "100%",
-                transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-              },
-              unmounted: {
-                y: "100%",
-              },
-            }
-      }
-      {...(!isLarge && {
-        style: { y },
-        drag: "y",
-        dragConstraints: { top: 0 },
-        dragElastic: false,
-        dragPropagation: true,
-        onDragStart: () => {
-          setIsDragging(true);
-        },
-        onDragEnd: (e, { velocity }, { state }) => {
-          setIsDragging(false);
-          if (e.type !== "pointercancel" && velocity.y > 10) {
-            state.close();
-          } else {
-            animate(y, "0", {
-              type: "inertia",
-              bounceStiffness: 300,
-              bounceDamping: 40,
-              timeConstant: 300,
-              min: 0,
-              max: 0,
-            });
-          }
-        },
-      })}
       className={clsx(styles.drawer, className)}
       data-has-footer={footer === undefined ? undefined : ""}
       data-fill={fill ? "" : undefined}
       data-hide-heading={hideHeading ? "" : undefined}
+      {...animationProps}
       {...props}
     >
       {(...args) => (
@@ -183,9 +120,7 @@ export const Drawer = ({
             </div>
             {headingAfter}
           </div>
-          <div className={clsx(styles.body, bodyClassName)}>
-            {typeof children === "function" ? children(...args) : children}
-          </div>
+          <div className={clsx(styles.body, bodyClassName)}>{contents}</div>
           {footer && (
             <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
           )}
@@ -195,6 +130,132 @@ export const Drawer = ({
   );
 };
 
+const useAnimationProps = (
+  variant: Props["variant"],
+  setMainContentOffset: (value: number) => void,
+): {
+  isDragging: boolean;
+  props: Partial<ComponentProps<typeof ModalDialog>>;
+} => {
+  const modalRef = useRef<null | HTMLDivElement>(null);
+  const [isDragging, setIsDragging] = useState(false);
+  const y = useMotionValue("100%");
+
+  useMotionValueEvent(y, "change", (y) => {
+    if (typeof y === "string") {
+      setMainContentOffset(100 - Number.parseInt(y.replace(/%$/, ""), 10));
+    } else if (modalRef.current) {
+      setMainContentOffset(100 - (100 * y) / modalRef.current.offsetHeight);
+    }
+  });
+  const isLarge = useMediaQuery(
+    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
+  );
+
+  const commonProps = {
+    ref: modalRef,
+  };
+
+  return isLarge
+    ? {
+        isDragging: false,
+        props: {
+          ...commonProps,
+          variants:
+            variant === "dialog"
+              ? {
+                  visible: {
+                    y: 0,
+                    transition: { type: "spring", duration: 0.8, bounce: 0.35 },
+                  },
+                  hidden: {
+                    y: "calc(-100% - 8rem)",
+                    transition: {
+                      ease: "linear",
+                      duration: CLOSE_DURATION_IN_S,
+                    },
+                  },
+                  unmounted: {
+                    y: "calc(-100% - 8rem)",
+                  },
+                }
+              : {
+                  visible: {
+                    x: 0,
+                    transition: { type: "spring", duration: 1, bounce: 0.35 },
+                  },
+                  hidden: {
+                    x: "calc(100% + 1rem)",
+                    transition: {
+                      ease: "linear",
+                      duration: CLOSE_DURATION_IN_S,
+                    },
+                  },
+                  unmounted: {
+                    x: "calc(100% + 1rem)",
+                  },
+                },
+        },
+      }
+    : {
+        isDragging,
+        props: {
+          ...commonProps,
+          variants: {
+            visible: {
+              y: 0,
+              transition: {
+                duration: 0.5,
+                ease: [0.32, 0.72, 0, 1],
+              },
+            },
+            hidden: {
+              y: "100%",
+              transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+            },
+            unmounted: {
+              y: "100%",
+            },
+          },
+          style: { y },
+          drag: "y",
+          dragConstraints: { top: 0 },
+          dragElastic: false,
+          dragPropagation: true,
+          onDragStart: () => {
+            setIsDragging(true);
+          },
+          onDragEnd: (e, { velocity }, { state }) => {
+            setIsDragging(false);
+            if (e.type !== "pointercancel" && velocity.y > 10) {
+              state.close();
+            } else {
+              animate(y, "0", {
+                type: "inertia",
+                bounceStiffness: 300,
+                bounceDamping: 40,
+                timeConstant: 300,
+                min: 0,
+                max: 0,
+              });
+            }
+          },
+        },
+      };
+};
+
+const { Provider, useValue } = createModalDialogContext<
+  Props,
+  Pick<Props, "setMainContentOffset">
+>(Drawer);
+
+export const DrawerProvider = Provider;
+export const useDrawer = useValue;
+export type OpenDrawerArgs = OpenArgs<
+  Props,
+  Pick<Props, "setMainContentOffset">
+>;
+
 type OnResizeProps = {
   threshold: string | undefined;
   onResize: () => void;

+ 347 - 91
pnpm-lock.yaml

@@ -46,8 +46,8 @@ catalogs:
       specifier: ^2.2.0
       version: 2.2.0
     '@next/third-parties':
-      specifier: ^15.2.4
-      version: 15.2.4
+      specifier: ^15.3.1
+      version: 15.3.1
     '@phosphor-icons/react':
       specifier: ^2.1.7
       version: 2.1.7
@@ -175,11 +175,11 @@ catalogs:
       specifier: ^3.0.1
       version: 3.0.1
     motion:
-      specifier: ^12.6.3
-      version: 12.6.3
+      specifier: ^12.9.2
+      version: 12.9.2
     next:
-      specifier: ^15.2.4
-      version: 15.2.4
+      specifier: ^15.3.1
+      version: 15.3.1
     next-themes:
       specifier: ^0.4.6
       version: 0.4.6
@@ -327,7 +327,7 @@ importers:
         version: 2.2.0(react@19.1.0)
       '@next/third-parties':
         specifier: 'catalog:'
-        version: 15.2.4(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+        version: 15.3.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
       '@pythnetwork/client':
         specifier: 'catalog:'
         version: 2.22.1(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)
@@ -357,7 +357,7 @@ importers:
         version: 12.6.3(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       next-themes:
         specifier: 'catalog:'
         version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -394,7 +394,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -469,7 +469,7 @@ importers:
         version: 0.487.0(react@19.1.0)
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       react:
         specifier: 'catalog:'
         version: 19.1.0
@@ -494,7 +494,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -636,16 +636,16 @@ importers:
         version: 5.0.5
       motion:
         specifier: 'catalog:'
-        version: 12.6.3(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 12.9.2(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       next-themes:
         specifier: 'catalog:'
         version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       nuqs:
         specifier: 'catalog:'
-        version: 2.4.1(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+        version: 2.4.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
       react:
         specifier: 'catalog:'
         version: 19.1.0
@@ -676,7 +676,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -881,7 +881,7 @@ importers:
         version: 2.2.0(react@19.1.0)
       '@next/third-parties':
         specifier: 'catalog:'
-        version: 15.2.4(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+        version: 15.3.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
       '@pythnetwork/hermes-client':
         specifier: workspace:*
         version: link:../hermes/client/js
@@ -929,7 +929,7 @@ importers:
         version: 0.2.0
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       pino:
         specifier: 'catalog:'
         version: 9.6.0
@@ -966,7 +966,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1196,7 +1196,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1509,13 +1509,13 @@ importers:
         version: link:../../../../pythnet/message_buffer
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       next-seo:
         specifier: ^5.15.0
-        version: 5.15.0(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 5.15.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       nuqs:
         specifier: 'catalog:'
-        version: 2.4.1(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+        version: 2.4.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
       react:
         specifier: 'catalog:'
         version: 19.1.0
@@ -1684,7 +1684,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1729,7 +1729,7 @@ importers:
         version: 3.0.1
       motion:
         specifier: 'catalog:'
-        version: 12.6.3(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 12.9.2(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       react-aria:
         specifier: 'catalog:'
         version: 3.38.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1742,7 +1742,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1766,7 +1766,7 @@ importers:
         version: 8.6.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))
       '@storybook/nextjs':
         specifier: 'catalog:'
-        version: 8.6.12(esbuild@0.25.2)(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))(type-fest@4.39.0)(typescript@5.8.2)(webpack-hot-middleware@2.26.1)(webpack@5.98.0(esbuild@0.25.2))
+        version: 8.6.12(esbuild@0.25.2)(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))(type-fest@4.39.0)(typescript@5.8.2)(webpack-hot-middleware@2.26.1)(webpack@5.98.0(esbuild@0.25.2))
       '@storybook/react':
         specifier: 'catalog:'
         version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))(typescript@5.8.2)
@@ -1790,7 +1790,7 @@ importers:
         version: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       postcss:
         specifier: 'catalog:'
         version: 8.5.3
@@ -1832,7 +1832,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1850,7 +1850,7 @@ importers:
         version: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       prettier:
         specifier: 'catalog:'
         version: 3.5.3
@@ -1865,7 +1865,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1907,7 +1907,7 @@ importers:
         version: 4.10.1
       '@next/third-parties':
         specifier: 'catalog:'
-        version: 15.2.4(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+        version: 15.3.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
       '@pythnetwork/app-logger':
         specifier: workspace:*
         version: link:../app-logger
@@ -1926,7 +1926,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)
@@ -1950,7 +1950,7 @@ importers:
         version: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))
       next:
         specifier: 'catalog:'
-        version: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       prettier:
         specifier: 'catalog:'
         version: 3.5.3
@@ -5260,105 +5260,215 @@ packages:
     cpu: [arm64]
     os: [darwin]
 
+  '@img/sharp-darwin-arm64@0.34.1':
+    resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [darwin]
+
   '@img/sharp-darwin-x64@0.33.5':
     resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [darwin]
 
+  '@img/sharp-darwin-x64@0.34.1':
+    resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [darwin]
+
   '@img/sharp-libvips-darwin-arm64@1.0.4':
     resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
     cpu: [arm64]
     os: [darwin]
 
+  '@img/sharp-libvips-darwin-arm64@1.1.0':
+    resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==}
+    cpu: [arm64]
+    os: [darwin]
+
   '@img/sharp-libvips-darwin-x64@1.0.4':
     resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
     cpu: [x64]
     os: [darwin]
 
+  '@img/sharp-libvips-darwin-x64@1.1.0':
+    resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==}
+    cpu: [x64]
+    os: [darwin]
+
   '@img/sharp-libvips-linux-arm64@1.0.4':
     resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
     cpu: [arm64]
     os: [linux]
 
+  '@img/sharp-libvips-linux-arm64@1.1.0':
+    resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
+    cpu: [arm64]
+    os: [linux]
+
   '@img/sharp-libvips-linux-arm@1.0.5':
     resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
     cpu: [arm]
     os: [linux]
 
+  '@img/sharp-libvips-linux-arm@1.1.0':
+    resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==}
+    cpu: [arm]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-ppc64@1.1.0':
+    resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==}
+    cpu: [ppc64]
+    os: [linux]
+
   '@img/sharp-libvips-linux-s390x@1.0.4':
     resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
     cpu: [s390x]
     os: [linux]
 
+  '@img/sharp-libvips-linux-s390x@1.1.0':
+    resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
+    cpu: [s390x]
+    os: [linux]
+
   '@img/sharp-libvips-linux-x64@1.0.4':
     resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
     cpu: [x64]
     os: [linux]
 
+  '@img/sharp-libvips-linux-x64@1.1.0':
+    resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
+    cpu: [x64]
+    os: [linux]
+
   '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
     resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
     cpu: [arm64]
     os: [linux]
 
+  '@img/sharp-libvips-linuxmusl-arm64@1.1.0':
+    resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==}
+    cpu: [arm64]
+    os: [linux]
+
   '@img/sharp-libvips-linuxmusl-x64@1.0.4':
     resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
     cpu: [x64]
     os: [linux]
 
+  '@img/sharp-libvips-linuxmusl-x64@1.1.0':
+    resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==}
+    cpu: [x64]
+    os: [linux]
+
   '@img/sharp-linux-arm64@0.33.5':
     resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
 
+  '@img/sharp-linux-arm64@0.34.1':
+    resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+
   '@img/sharp-linux-arm@0.33.5':
     resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm]
     os: [linux]
 
+  '@img/sharp-linux-arm@0.34.1':
+    resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm]
+    os: [linux]
+
   '@img/sharp-linux-s390x@0.33.5':
     resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [s390x]
     os: [linux]
 
+  '@img/sharp-linux-s390x@0.34.1':
+    resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [s390x]
+    os: [linux]
+
   '@img/sharp-linux-x64@0.33.5':
     resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
 
+  '@img/sharp-linux-x64@0.34.1':
+    resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+
   '@img/sharp-linuxmusl-arm64@0.33.5':
     resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
 
+  '@img/sharp-linuxmusl-arm64@0.34.1':
+    resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+
   '@img/sharp-linuxmusl-x64@0.33.5':
     resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
 
+  '@img/sharp-linuxmusl-x64@0.34.1':
+    resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+
   '@img/sharp-wasm32@0.33.5':
     resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [wasm32]
 
+  '@img/sharp-wasm32@0.34.1':
+    resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [wasm32]
+
   '@img/sharp-win32-ia32@0.33.5':
     resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [ia32]
     os: [win32]
 
+  '@img/sharp-win32-ia32@0.34.1':
+    resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [ia32]
+    os: [win32]
+
   '@img/sharp-win32-x64@0.33.5':
     resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [win32]
 
+  '@img/sharp-win32-x64@0.34.1':
+    resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [win32]
+
   '@improbable-eng/grpc-web@0.14.1':
     resolution: {integrity: sha512-XaIYuunepPxoiGVLLHmlnVminUGzBTnXr8Wv7khzmLWbNw4TCwJKX09GSMJlKhu/TRk6gms0ySFxewaETSBqgw==}
     peerDependencies:
@@ -5928,8 +6038,8 @@ packages:
   '@near-js/wallet-account@1.1.1':
     resolution: {integrity: sha512-NnoJKtogBQ7Qz+AP+LdF70BP8Az6UXQori7OjPqJLMo73bn6lh5Ywvegwd1EB7ZEVe4BRt9+f9QkbU5M8ANfAw==}
 
-  '@next/env@15.2.4':
-    resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==}
+  '@next/env@15.3.1':
+    resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==}
 
   '@next/eslint-plugin-next@14.2.26':
     resolution: {integrity: sha512-SPEj1O5DAVTPaWD9XPupelfT2APNIgcDYD2OzEm328BEmHaglhmYNUvxhzfJYDr12AgAfW4V3UHSV93qaeELJA==}
@@ -5937,56 +6047,56 @@ packages:
   '@next/eslint-plugin-next@15.2.4':
     resolution: {integrity: sha512-O8ScvKtnxkp8kL9TpJTTKnMqlkZnS+QxwoQnJwPGBxjBbzd6OVVPEJ5/pMNrktSyXQD/chEfzfFzYLM6JANOOQ==}
 
-  '@next/swc-darwin-arm64@15.2.4':
-    resolution: {integrity: sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==}
+  '@next/swc-darwin-arm64@15.3.1':
+    resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [darwin]
 
-  '@next/swc-darwin-x64@15.2.4':
-    resolution: {integrity: sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==}
+  '@next/swc-darwin-x64@15.3.1':
+    resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [darwin]
 
-  '@next/swc-linux-arm64-gnu@15.2.4':
-    resolution: {integrity: sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==}
+  '@next/swc-linux-arm64-gnu@15.3.1':
+    resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
-  '@next/swc-linux-arm64-musl@15.2.4':
-    resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
+  '@next/swc-linux-arm64-musl@15.3.1':
+    resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
-  '@next/swc-linux-x64-gnu@15.2.4':
-    resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
+  '@next/swc-linux-x64-gnu@15.3.1':
+    resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
-  '@next/swc-linux-x64-musl@15.2.4':
-    resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
+  '@next/swc-linux-x64-musl@15.3.1':
+    resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
-  '@next/swc-win32-arm64-msvc@15.2.4':
-    resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
+  '@next/swc-win32-arm64-msvc@15.3.1':
+    resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [win32]
 
-  '@next/swc-win32-x64-msvc@15.2.4':
-    resolution: {integrity: sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==}
+  '@next/swc-win32-x64-msvc@15.3.1':
+    resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [win32]
 
-  '@next/third-parties@15.2.4':
-    resolution: {integrity: sha512-a8GlPnMmPymxyLOiSnh5InUsG/hw7wjU3munGoHNB+oLCPruAeoplBa9Uf/xE83WMyutyK4cbi5Ixu4uyh96Mw==}
+  '@next/third-parties@15.3.1':
+    resolution: {integrity: sha512-8v1pAtRjcaCbs80qcYLLCrSsECgeSb0WMU0J3pMBYNavG3Y3yQOgFog18nVgiNrNB20HkyrScquWsy8gcdiGRA==}
     peerDependencies:
       next: ^13.0.0 || ^14.0.0 || ^15.0.0
       react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
@@ -14352,6 +14462,20 @@ packages:
       react-dom:
         optional: true
 
+  framer-motion@12.9.2:
+    resolution: {integrity: sha512-R0O3Jdqbfwywpm45obP+8sTgafmdEcUoShQTAV+rB5pi+Y1Px/FYL5qLLRe5tPtBdN1J4jos7M+xN2VV2oEAbQ==}
+    peerDependencies:
+      '@emotion/is-prop-valid': '*'
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@emotion/is-prop-valid':
+        optional: true
+      react:
+        optional: true
+      react-dom:
+        optional: true
+
   framer-motion@6.5.1:
     resolution: {integrity: sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==}
     peerDependencies:
@@ -16696,14 +16820,20 @@ packages:
   motion-dom@12.6.3:
     resolution: {integrity: sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA==}
 
+  motion-dom@12.9.1:
+    resolution: {integrity: sha512-xqXEwRLDYDTzOgXobSoWtytRtGlf7zdkRfFbrrdP7eojaGQZ5Go4OOKtgnx7uF8sAkfr1ZjMvbCJSCIT2h6fkQ==}
+
   motion-utils@12.6.3:
     resolution: {integrity: sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg==}
 
+  motion-utils@12.8.3:
+    resolution: {integrity: sha512-GYVauZEbca8/zOhEiYOY9/uJeedYQld6co/GJFKOy//0c/4lDqk0zB549sBYqqV2iMuX+uHrY1E5zd8A2L+1Lw==}
+
   motion@10.16.2:
     resolution: {integrity: sha512-p+PurYqfUdcJZvtnmAqu5fJgV2kR0uLFQuBKtLeFVTrYEVllI99tiOTSefVNYuip9ELTEkepIIDftNdze76NAQ==}
 
-  motion@12.6.3:
-    resolution: {integrity: sha512-zw/vqUgv5F5m9fkvOl/eCv2AF1+tkeZl3fu2uIlisIaip8sm5e0CouAl6GkdiRoF+G7s29CjqMdIyPMirwUGHA==}
+  motion@12.9.2:
+    resolution: {integrity: sha512-2hwi4wlOpt/zDHcDZATL2FFhYgj2n6t5Hd0UT91swMup6dx6KpFRkTydYJkkV0PUImT1QfC+WT5d0eRekTKpcg==}
     peerDependencies:
       '@emotion/is-prop-valid': '*'
       react: ^18.0.0 || ^19.0.0
@@ -16852,8 +16982,8 @@ packages:
   next-tick@1.1.0:
     resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
 
-  next@15.2.4:
-    resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
+  next@15.3.1:
+    resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==}
     engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
     hasBin: true
     peerDependencies:
@@ -18926,6 +19056,10 @@ packages:
     resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
 
+  sharp@0.34.1:
+    resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
   shebang-command@2.0.0:
     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
     engines: {node: '>=8'}
@@ -23734,7 +23868,7 @@ snapshots:
       - turbo
       - typescript
 
-  '@cprussin/jest-config@2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)':
+  '@cprussin/jest-config@2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(esbuild@0.25.2)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)':
     dependencies:
       '@cprussin/jest-runner-eslint': 0.0.1(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))
       '@cprussin/jest-runner-prettier': 1.0.0(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(prettier@3.5.3)
@@ -23745,7 +23879,7 @@ snapshots:
       ts-jest: 29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.25.2)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(typescript@5.8.2)
       typescript: 5.8.2
     optionalDependencies:
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
     transitivePeerDependencies:
       - '@babel/core'
       - '@jest/test-result'
@@ -23760,7 +23894,7 @@ snapshots:
       - supports-color
       - utf-8-validate
 
-  '@cprussin/jest-config@2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)':
+  '@cprussin/jest-config@2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)':
     dependencies:
       '@cprussin/jest-runner-eslint': 0.0.1(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))
       '@cprussin/jest-runner-prettier': 1.0.0(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(prettier@3.5.3)
@@ -23771,7 +23905,7 @@ snapshots:
       ts-jest: 29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.25.2)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(typescript@5.8.2)
       typescript: 5.8.2
     optionalDependencies:
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
     transitivePeerDependencies:
       - '@babel/core'
       - '@jest/test-result'
@@ -25439,76 +25573,154 @@ snapshots:
       '@img/sharp-libvips-darwin-arm64': 1.0.4
     optional: true
 
+  '@img/sharp-darwin-arm64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-arm64': 1.1.0
+    optional: true
+
   '@img/sharp-darwin-x64@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-darwin-x64': 1.0.4
     optional: true
 
+  '@img/sharp-darwin-x64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-x64': 1.1.0
+    optional: true
+
   '@img/sharp-libvips-darwin-arm64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-darwin-arm64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-darwin-x64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-darwin-x64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linux-arm64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-linux-arm64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linux-arm@1.0.5':
     optional: true
 
+  '@img/sharp-libvips-linux-arm@1.1.0':
+    optional: true
+
+  '@img/sharp-libvips-linux-ppc64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linux-s390x@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-linux-s390x@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linux-x64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-linux-x64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-linuxmusl-arm64@1.1.0':
+    optional: true
+
   '@img/sharp-libvips-linuxmusl-x64@1.0.4':
     optional: true
 
+  '@img/sharp-libvips-linuxmusl-x64@1.1.0':
+    optional: true
+
   '@img/sharp-linux-arm64@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linux-arm64': 1.0.4
     optional: true
 
+  '@img/sharp-linux-arm64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm64': 1.1.0
+    optional: true
+
   '@img/sharp-linux-arm@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linux-arm': 1.0.5
     optional: true
 
+  '@img/sharp-linux-arm@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm': 1.1.0
+    optional: true
+
   '@img/sharp-linux-s390x@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linux-s390x': 1.0.4
     optional: true
 
+  '@img/sharp-linux-s390x@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-s390x': 1.1.0
+    optional: true
+
   '@img/sharp-linux-x64@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linux-x64': 1.0.4
     optional: true
 
+  '@img/sharp-linux-x64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-x64': 1.1.0
+    optional: true
+
   '@img/sharp-linuxmusl-arm64@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
     optional: true
 
+  '@img/sharp-linuxmusl-arm64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-arm64': 1.1.0
+    optional: true
+
   '@img/sharp-linuxmusl-x64@0.33.5':
     optionalDependencies:
       '@img/sharp-libvips-linuxmusl-x64': 1.0.4
     optional: true
 
+  '@img/sharp-linuxmusl-x64@0.34.1':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-x64': 1.1.0
+    optional: true
+
   '@img/sharp-wasm32@0.33.5':
     dependencies:
       '@emnapi/runtime': 1.4.0
     optional: true
 
+  '@img/sharp-wasm32@0.34.1':
+    dependencies:
+      '@emnapi/runtime': 1.4.0
+    optional: true
+
   '@img/sharp-win32-ia32@0.33.5':
     optional: true
 
+  '@img/sharp-win32-ia32@0.34.1':
+    optional: true
+
   '@img/sharp-win32-x64@0.33.5':
     optional: true
 
+  '@img/sharp-win32-x64@0.34.1':
+    optional: true
+
   '@improbable-eng/grpc-web@0.14.1(google-protobuf@3.21.4)':
     dependencies:
       browser-headers: 0.4.1
@@ -27256,7 +27468,7 @@ snapshots:
     transitivePeerDependencies:
       - encoding
 
-  '@next/env@15.2.4': {}
+  '@next/env@15.3.1': {}
 
   '@next/eslint-plugin-next@14.2.26':
     dependencies:
@@ -27266,33 +27478,33 @@ snapshots:
     dependencies:
       fast-glob: 3.3.1
 
-  '@next/swc-darwin-arm64@15.2.4':
+  '@next/swc-darwin-arm64@15.3.1':
     optional: true
 
-  '@next/swc-darwin-x64@15.2.4':
+  '@next/swc-darwin-x64@15.3.1':
     optional: true
 
-  '@next/swc-linux-arm64-gnu@15.2.4':
+  '@next/swc-linux-arm64-gnu@15.3.1':
     optional: true
 
-  '@next/swc-linux-arm64-musl@15.2.4':
+  '@next/swc-linux-arm64-musl@15.3.1':
     optional: true
 
-  '@next/swc-linux-x64-gnu@15.2.4':
+  '@next/swc-linux-x64-gnu@15.3.1':
     optional: true
 
-  '@next/swc-linux-x64-musl@15.2.4':
+  '@next/swc-linux-x64-musl@15.3.1':
     optional: true
 
-  '@next/swc-win32-arm64-msvc@15.2.4':
+  '@next/swc-win32-arm64-msvc@15.3.1':
     optional: true
 
-  '@next/swc-win32-x64-msvc@15.2.4':
+  '@next/swc-win32-x64-msvc@15.3.1':
     optional: true
 
-  '@next/third-parties@15.2.4(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)':
+  '@next/third-parties@15.3.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)':
     dependencies:
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       react: 19.1.0
       third-party-capital: 1.0.20
 
@@ -32444,7 +32656,7 @@ snapshots:
     dependencies:
       storybook: 8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3)
 
-  '@storybook/nextjs@8.6.12(esbuild@0.25.2)(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))(type-fest@4.39.0)(typescript@5.8.2)(webpack-hot-middleware@2.26.1)(webpack@5.98.0(esbuild@0.25.2))':
+  '@storybook/nextjs@8.6.12(esbuild@0.25.2)(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3))(type-fest@4.39.0)(typescript@5.8.2)(webpack-hot-middleware@2.26.1)(webpack@5.98.0(esbuild@0.25.2))':
     dependencies:
       '@babel/core': 7.26.10
       '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.26.10)
@@ -32470,7 +32682,7 @@ snapshots:
       find-up: 5.0.0
       image-size: 1.2.1
       loader-utils: 3.3.1
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       node-polyfill-webpack-plugin: 2.0.1(webpack@5.98.0(esbuild@0.25.2))
       pnp-webpack-plugin: 1.7.0(typescript@5.8.2)
       postcss: 8.5.3
@@ -40335,6 +40547,16 @@ snapshots:
       react: 19.1.0
       react-dom: 19.1.0(react@19.1.0)
 
+  framer-motion@12.9.2(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+    dependencies:
+      motion-dom: 12.9.1
+      motion-utils: 12.8.3
+      tslib: 2.8.1
+    optionalDependencies:
+      '@emotion/is-prop-valid': 1.3.1
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+
   framer-motion@6.5.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
     dependencies:
       '@motionone/dom': 10.12.0
@@ -44018,8 +44240,14 @@ snapshots:
     dependencies:
       motion-utils: 12.6.3
 
+  motion-dom@12.9.1:
+    dependencies:
+      motion-utils: 12.8.3
+
   motion-utils@12.6.3: {}
 
+  motion-utils@12.8.3: {}
+
   motion@10.16.2:
     dependencies:
       '@motionone/animation': 10.18.0
@@ -44029,9 +44257,9 @@ snapshots:
       '@motionone/utils': 10.18.0
       '@motionone/vue': 10.16.4
 
-  motion@12.6.3(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+  motion@12.9.2(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
     dependencies:
-      framer-motion: 12.6.3(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      framer-motion: 12.9.2(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       tslib: 2.8.1
     optionalDependencies:
       '@emotion/is-prop-valid': 1.3.1
@@ -44168,9 +44396,9 @@ snapshots:
     dependencies:
       ansi-regex: 2.1.1
 
-  next-seo@5.15.0(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+  next-seo@5.15.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
     dependencies:
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
       react: 19.1.0
       react-dom: 19.1.0(react@19.1.0)
 
@@ -44181,9 +44409,9 @@ snapshots:
 
   next-tick@1.1.0: {}
 
-  next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1):
+  next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1):
     dependencies:
-      '@next/env': 15.2.4
+      '@next/env': 15.3.1
       '@swc/counter': 0.1.3
       '@swc/helpers': 0.5.15
       busboy: 1.6.0
@@ -44193,17 +44421,17 @@ snapshots:
       react-dom: 19.1.0(react@19.1.0)
       styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.1.0)
     optionalDependencies:
-      '@next/swc-darwin-arm64': 15.2.4
-      '@next/swc-darwin-x64': 15.2.4
-      '@next/swc-linux-arm64-gnu': 15.2.4
-      '@next/swc-linux-arm64-musl': 15.2.4
-      '@next/swc-linux-x64-gnu': 15.2.4
-      '@next/swc-linux-x64-musl': 15.2.4
-      '@next/swc-win32-arm64-msvc': 15.2.4
-      '@next/swc-win32-x64-msvc': 15.2.4
+      '@next/swc-darwin-arm64': 15.3.1
+      '@next/swc-darwin-x64': 15.3.1
+      '@next/swc-linux-arm64-gnu': 15.3.1
+      '@next/swc-linux-arm64-musl': 15.3.1
+      '@next/swc-linux-x64-gnu': 15.3.1
+      '@next/swc-linux-x64-musl': 15.3.1
+      '@next/swc-win32-arm64-msvc': 15.3.1
+      '@next/swc-win32-x64-msvc': 15.3.1
       '@opentelemetry/api': 1.9.0
       sass: 1.86.1
-      sharp: 0.33.5
+      sharp: 0.34.1
     transitivePeerDependencies:
       - '@babel/core'
       - babel-plugin-macros
@@ -44383,12 +44611,12 @@ snapshots:
       bn.js: 4.11.6
       strip-hex-prefix: 1.0.0
 
-  nuqs@2.4.1(next@15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0):
+  nuqs@2.4.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0):
     dependencies:
       mitt: 3.0.1
       react: 19.1.0
     optionalDependencies:
-      next: 15.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
 
   nwsapi@2.2.20: {}
 
@@ -46806,6 +47034,34 @@ snapshots:
       '@img/sharp-win32-ia32': 0.33.5
       '@img/sharp-win32-x64': 0.33.5
 
+  sharp@0.34.1:
+    dependencies:
+      color: 4.2.3
+      detect-libc: 2.0.3
+      semver: 7.7.1
+    optionalDependencies:
+      '@img/sharp-darwin-arm64': 0.34.1
+      '@img/sharp-darwin-x64': 0.34.1
+      '@img/sharp-libvips-darwin-arm64': 1.1.0
+      '@img/sharp-libvips-darwin-x64': 1.1.0
+      '@img/sharp-libvips-linux-arm': 1.1.0
+      '@img/sharp-libvips-linux-arm64': 1.1.0
+      '@img/sharp-libvips-linux-ppc64': 1.1.0
+      '@img/sharp-libvips-linux-s390x': 1.1.0
+      '@img/sharp-libvips-linux-x64': 1.1.0
+      '@img/sharp-libvips-linuxmusl-arm64': 1.1.0
+      '@img/sharp-libvips-linuxmusl-x64': 1.1.0
+      '@img/sharp-linux-arm': 0.34.1
+      '@img/sharp-linux-arm64': 0.34.1
+      '@img/sharp-linux-s390x': 0.34.1
+      '@img/sharp-linux-x64': 0.34.1
+      '@img/sharp-linuxmusl-arm64': 0.34.1
+      '@img/sharp-linuxmusl-x64': 0.34.1
+      '@img/sharp-wasm32': 0.34.1
+      '@img/sharp-win32-ia32': 0.34.1
+      '@img/sharp-win32-x64': 0.34.1
+    optional: true
+
   shebang-command@2.0.0:
     dependencies:
       shebang-regex: 3.0.0

+ 3 - 3
pnpm-workspace.yaml

@@ -58,7 +58,7 @@ catalog:
   "@floating-ui/react": ^0.27.6
   "@headlessui/react": ^2.2.0
   "@heroicons/react": ^2.2.0
-  "@next/third-parties": ^15.2.4
+  "@next/third-parties": ^15.3.1
   "@phosphor-icons/react": ^2.1.7
   "@pythnetwork/client": ^2.22.1
   "@pythnetwork/pyth-sdk-solidity": ^4.0.0
@@ -102,8 +102,8 @@ catalog:
   lightweight-charts: ^5.0.5
   lucide-react: ^0.487.0
   modern-normalize: ^3.0.1
-  motion: ^12.6.3
-  next: ^15.2.4
+  motion: ^12.9.2
+  next: ^15.3.1
   next-themes: ^0.4.6
   nuqs: ^2.4.1
   pino: ^9.6.0