Przeglądaj źródła

Merge pull request #2206 from pyth-network/cprussin/add-component-table

feat(staking): add price components table
Connor Prussin 11 miesięcy temu
rodzic
commit
f0659ce536
64 zmienionych plików z 1794 dodań i 999 usunięć
  1. 1 0
      apps/insights/package.json
  2. 3 3
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  3. 11 0
      apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx
  4. 1 0
      apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx
  5. 3 1
      apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx
  6. 16 0
      apps/insights/src/cache.ts
  7. 13 5
      apps/insights/src/components/CopyButton/index.tsx
  8. 2 15
      apps/insights/src/components/FeedKey/index.tsx
  9. 13 0
      apps/insights/src/components/FormattedNumber/index.tsx
  10. 1 1
      apps/insights/src/components/LayoutTransition/index.tsx
  11. 2 2
      apps/insights/src/components/NotFound/index.tsx
  12. 36 55
      apps/insights/src/components/PriceFeed/layout.tsx
  13. 44 0
      apps/insights/src/components/PriceFeed/price-component-drawer.tsx
  14. 206 0
      apps/insights/src/components/PriceFeed/price-components-card.tsx
  15. 8 0
      apps/insights/src/components/PriceFeed/price-components.module.scss
  16. 91 1
      apps/insights/src/components/PriceFeed/price-components.tsx
  17. 203 0
      apps/insights/src/components/PriceFeed/price-feed-select.module.scss
  18. 102 0
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  19. 20 13
      apps/insights/src/components/PriceFeedTag/index.tsx
  20. 42 26
      apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx
  21. 0 1
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  22. 3 29
      apps/insights/src/components/PriceFeeds/index.tsx
  23. 21 3
      apps/insights/src/components/PriceFeeds/layout.tsx
  24. 44 43
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  25. 0 72
      apps/insights/src/components/PriceFeeds/query-params.ts
  26. 52 0
      apps/insights/src/components/PublisherTag/index.module.scss
  27. 64 0
      apps/insights/src/components/PublisherTag/index.tsx
  28. 0 50
      apps/insights/src/components/Publishers/index.module.scss
  29. 19 72
      apps/insights/src/components/Publishers/index.tsx
  30. 0 66
      apps/insights/src/components/Publishers/publishers-card.module.scss
  31. 37 156
      apps/insights/src/components/Publishers/publishers-card.tsx
  32. 13 7
      apps/insights/src/components/Root/footer.tsx
  33. 3 3
      apps/insights/src/components/Root/header.tsx
  34. 11 6
      apps/insights/src/components/Root/theme-switch.tsx
  35. 66 0
      apps/insights/src/components/Score/index.module.scss
  36. 63 0
      apps/insights/src/components/Score/index.tsx
  37. 7 0
      apps/insights/src/hex.ts
  38. 42 0
      apps/insights/src/services/clickhouse.ts
  39. 45 0
      apps/insights/src/services/pyth.ts
  40. 98 0
      apps/insights/src/use-query-param-filter-pagination.ts
  41. 0 18
      apps/insights/src/zod-utils.ts
  42. 40 45
      packages/component-library/src/Alert/index.module.scss
  43. 27 23
      packages/component-library/src/Alert/index.tsx
  44. 3 3
      packages/component-library/src/Breadcrumbs/index.tsx
  45. 0 40
      packages/component-library/src/Button/button-link.stories.tsx
  46. 12 0
      packages/component-library/src/Button/index.stories.tsx
  47. 19 15
      packages/component-library/src/Button/index.tsx
  48. 3 3
      packages/component-library/src/Card/index.module.scss
  49. 25 30
      packages/component-library/src/Drawer/index.module.scss
  50. 39 27
      packages/component-library/src/Drawer/index.tsx
  51. 14 0
      packages/component-library/src/DropdownCaretDown/index.tsx
  52. 1 0
      packages/component-library/src/Html/base.scss
  53. 0 1
      packages/component-library/src/MainNavTabs/index.tsx
  54. 0 78
      packages/component-library/src/Modal/index.tsx
  55. 127 0
      packages/component-library/src/ModalDialog/index.tsx
  56. 6 5
      packages/component-library/src/Paginator/index.tsx
  57. 1 3
      packages/component-library/src/Select/index.module.scss
  58. 4 15
      packages/component-library/src/Select/index.tsx
  59. 4 33
      packages/component-library/src/Table/index.module.scss
  60. 0 7
      packages/component-library/src/Table/index.stories.tsx
  61. 0 3
      packages/component-library/src/Table/index.tsx
  62. 0 1
      packages/component-library/src/Tabs/index.tsx
  63. 61 18
      pnpm-lock.yaml
  64. 2 1
      pnpm-workspace.yaml

+ 1 - 0
apps/insights/package.json

@@ -44,6 +44,7 @@
     "react-aria-components": "catalog:",
     "react-dom": "catalog:",
     "recharts": "catalog:",
+    "superjson": "catalog:",
     "swr": "catalog:",
     "zod": "catalog:"
   },

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

@@ -1,6 +1,6 @@
 import type { Metadata } from "next";
 
-import { client } from "../../../services/pyth";
+import { getData } from "../../../services/pyth";
 export { PriceFeedLayout as default } from "../../../components/PriceFeed/layout";
 
 export const metadata: Metadata = {
@@ -8,6 +8,6 @@ export const metadata: Metadata = {
 };
 
 export const generateStaticParams = async () => {
-  const data = await client.getData();
-  return data.symbols.map((symbol) => ({ slug: encodeURIComponent(symbol) }));
+  const data = await getData();
+  return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
 };

+ 11 - 0
apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx

@@ -0,0 +1,11 @@
+type Props = {
+  params: Promise<{
+    componentId: string;
+  }>;
+};
+
+const PriceFeedComponent = async ({ params }: Props) => {
+  const { componentId } = await params;
+  return componentId;
+};
+export default PriceFeedComponent;

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

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

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

@@ -1 +1,3 @@
-export { PriceComponents as default } from "../../../../components/PriceFeed/price-components";
+// eslint-disable-next-line unicorn/no-null
+const Page = () => null;
+export default Page;

+ 16 - 0
apps/insights/src/cache.ts

@@ -0,0 +1,16 @@
+import { unstable_cache } from "next/cache";
+import { parse, stringify } from "superjson";
+
+export const cache = <T, P extends unknown[]>(
+  fn: (...params: P) => Promise<T>,
+  keys?: Parameters<typeof unstable_cache>[1],
+  opts?: Parameters<typeof unstable_cache>[2],
+) => {
+  const cachedFn = unstable_cache(
+    async (params: P): Promise<string> => stringify(await fn(...params)),
+    keys,
+    opts,
+  );
+
+  return async (...params: P): Promise<T> => parse(await cachedFn(params));
+};

+ 13 - 5
apps/insights/src/components/CopyButton/index.tsx

@@ -3,9 +3,12 @@
 import { Check } from "@phosphor-icons/react/dist/ssr/Check";
 import { Copy } from "@phosphor-icons/react/dist/ssr/Copy";
 import { useLogger } from "@pythnetwork/app-logger";
-import { Button } from "@pythnetwork/component-library/Button";
+import {
+  type Props as ButtonProps,
+  Button,
+} from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
-import { type ComponentProps, useCallback, useEffect, useState } from "react";
+import { type ElementType, useCallback, useEffect, useState } from "react";
 
 import styles from "./index.module.scss";
 
@@ -13,13 +16,18 @@ type OwnProps = {
   text: string;
 };
 
-type Props = Omit<
-  ComponentProps<typeof Button>,
+type Props<T extends ElementType> = Omit<
+  ButtonProps<T>,
   keyof OwnProps | "onPress" | "afterIcon"
 > &
   OwnProps;
 
-export const CopyButton = ({ text, children, className, ...props }: Props) => {
+export const CopyButton = <T extends ElementType>({
+  text,
+  children,
+  className,
+  ...props
+}: Props<T>) => {
   const [isCopied, setIsCopied] = useState(false);
   const logger = useLogger();
   const copy = useCallback(() => {

+ 2 - 15
apps/insights/src/components/FeedKey/index.tsx

@@ -1,6 +1,6 @@
-import base58 from "bs58";
 import { useMemo, type ComponentProps } from "react";
 
+import { toHex, truncateHex } from "../../hex";
 import { CopyButton } from "../CopyButton";
 
 type OwnProps = {
@@ -22,10 +22,7 @@ export const FeedKey = ({ feed, ...props }: Props) => {
     () => toHex(feed.product.price_account),
     [feed.product.price_account],
   );
-  const truncatedKey = useMemo(
-    () => toTruncatedHex(feed.product.price_account),
-    [feed.product.price_account],
-  );
+  const truncatedKey = useMemo(() => truncateHex(key), [key]);
 
   return (
     <CopyButton text={key} {...props}>
@@ -33,13 +30,3 @@ export const FeedKey = ({ feed, ...props }: Props) => {
     </CopyButton>
   );
 };
-
-const toHex = (value: string) => toHexString(base58.decode(value));
-
-const toTruncatedHex = (value: string) => {
-  const hex = toHex(value);
-  return `${hex.slice(0, 6)}...${hex.slice(-4)}`;
-};
-
-const toHexString = (byteArray: Uint8Array) =>
-  `0x${Array.from(byteArray, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;

+ 13 - 0
apps/insights/src/components/FormattedNumber/index.tsx

@@ -0,0 +1,13 @@
+"use client";
+
+import { useMemo } from "react";
+import { useNumberFormatter } from "react-aria";
+
+type Props = Parameters<typeof useNumberFormatter>[0] & {
+  value: number;
+};
+
+export const FormattedNumber = ({ value, ...args }: Props) => {
+  const numberFormatter = useNumberFormatter(args);
+  return useMemo(() => numberFormatter.format(value), [numberFormatter, value]);
+};

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

@@ -53,7 +53,7 @@ export const LayoutTransition = ({ children, ...props }: Props) => {
 
   return (
     <AnimatePresence
-      mode="wait"
+      mode="popLayout"
       initial={false}
       onExitComplete={updatePrevSegment}
       custom={{ segment, prevSegment: prevSegment.current }}

+ 2 - 2
apps/insights/src/components/NotFound/index.tsx

@@ -1,9 +1,9 @@
-import { ButtonLink } from "@pythnetwork/component-library/Button";
+import { Button } from "@pythnetwork/component-library/Button";
 
 export const NotFound = () => (
   <div>
     <h1>Not Found</h1>
     <p>{"The page you're looking for isn't here"}</p>
-    <ButtonLink href="/">Go Home</ButtonLink>
+    <Button href="/">Go Home</Button>
   </div>
 );

+ 36 - 55
apps/insights/src/components/PriceFeed/layout.tsx

@@ -4,20 +4,26 @@ import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
 import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
-import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
+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 { z } from "zod";
 
 import styles from "./layout.module.scss";
+import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
 import { TabPanel, TabRoot, Tabs } from "./tabs";
-import { client } from "../../services/pyth";
+import { toHex } from "../../hex";
+import { getData } from "../../services/pyth";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
 import { FeedKey } from "../FeedKey";
-import { LivePrice, LiveConfidence, LiveLastUpdated } from "../LivePrices";
-import { NotFound } from "../NotFound";
+import {
+  LivePrice,
+  LiveConfidence,
+  LiveLastUpdated,
+  LiveValue,
+} from "../LivePrices";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
@@ -28,8 +34,9 @@ type Props = {
 };
 
 export const PriceFeedLayout = async ({ children, params }: Props) => {
-  const { slug } = await params;
-  const feed = await getPriceFeed(decodeURIComponent(slug));
+  const [{ slug }, data] = await Promise.all([params, getData()]);
+  const symbol = decodeURIComponent(slug);
+  const feed = data.find((item) => item.symbol === symbol);
 
   return feed ? (
     <div className={styles.priceFeedLayout}>
@@ -50,7 +57,24 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           </div>
         </div>
         <div className={styles.headerRow}>
-          <PriceFeedTag feed={feed} />
+          <PriceFeedSelect
+            feeds={data
+              .filter((feed) => feed.symbol !== symbol)
+              .map((feed) => ({
+                id: encodeURIComponent(feed.symbol),
+                key: toHex(feed.product.price_account),
+                displaySymbol: feed.product.display_symbol,
+                name: <PriceFeedTag compact feed={feed} />,
+                assetClassText: feed.product.asset_type,
+                assetClass: (
+                  <Badge variant="neutral" style="outline" size="xs">
+                    {feed.product.asset_type.toUpperCase()}
+                  </Badge>
+                ),
+              }))}
+          >
+            <PriceFeedTag feed={feed} />
+          </PriceFeedSelect>
           <div className={styles.rightGroup}>
             <FeedKey
               variant="ghost"
@@ -96,14 +120,14 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
                     of the confidence of individual quoters and how well
                     individual quoters agree with each other.
                   </p>
-                  <ButtonLink
+                  <Button
                     size="xs"
                     variant="solid"
                     href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
                     target="_blank"
                   >
                     Learn more
-                  </ButtonLink>
+                  </Button>
                 </Alert>
               </AlertTrigger>
             }
@@ -134,7 +158,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
                 <div className={styles.priceComponentsTabLabel}>
                   <span>Price Components</span>
                   <Badge size="xs" style="filled" variant="neutral">
-                    {feed.price.numComponentPrices}
+                    <LiveValue feed={feed} field="numComponentPrices" />
                   </Badge>
                 </div>
               ),
@@ -145,49 +169,6 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
       </TabRoot>
     </div>
   ) : (
-    <NotFound />
+    notFound()
   );
 };
-
-const getPriceFeed = async (symbol: string) => {
-  const data = await client.getData();
-  const priceFeeds = priceFeedsSchema.parse(
-    data.symbols.map((symbol) => ({
-      symbol,
-      product: data.productFromSymbol.get(symbol),
-      price: data.productPrice.get(symbol),
-    })),
-  );
-  return priceFeeds.find((feed) => feed.symbol === symbol);
-};
-
-const priceFeedsSchema = z.array(
-  z.object({
-    symbol: z.string(),
-    product: z.object({
-      display_symbol: z.string(),
-      asset_type: z.string(),
-      description: z.string(),
-      price_account: z.string(),
-      base: z.string().optional(),
-      country: z.string().optional(),
-      quote_currency: z.string().optional(),
-      tenor: z.string().optional(),
-      cms_symbol: z.string().optional(),
-      cqs_symbol: z.string().optional(),
-      nasdaq_symbol: z.string().optional(),
-      generic_symbol: z.string().optional(),
-      weekly_schedule: z.string().optional(),
-      schedule: z.string().optional(),
-      contract_id: z.string().optional(),
-    }),
-    price: z.object({
-      exponent: z.number(),
-      numComponentPrices: z.number(),
-      numQuoters: z.number(),
-      minPublishers: z.number(),
-      lastSlot: z.bigint(),
-      validSlot: z.bigint(),
-    }),
-  }),
-);

+ 44 - 0
apps/insights/src/components/PriceFeed/price-component-drawer.tsx

@@ -0,0 +1,44 @@
+"use client";
+
+import { Drawer } from "@pythnetwork/component-library/Drawer";
+import {
+  useSelectedLayoutSegment,
+  usePathname,
+  useRouter,
+} from "next/navigation";
+import { type ReactNode, useMemo, useCallback } from "react";
+
+type Props = {
+  children: ReactNode;
+};
+
+export const PriceComponentDrawer = ({ children }: Props) => {
+  const pathname = usePathname();
+  const segment = useSelectedLayoutSegment();
+  const prevUrl = useMemo(
+    () =>
+      segment ? pathname.replace(new RegExp(`/${segment}$`), "") : pathname,
+    [pathname, segment],
+  );
+  const router = useRouter();
+
+  const onOpenChange = useCallback(
+    (isOpen: boolean) => {
+      if (!isOpen) {
+        router.push(prevUrl);
+      }
+    },
+    [router, prevUrl],
+  );
+
+  return (
+    <Drawer
+      title="Hello!"
+      closeHref={prevUrl}
+      onOpenChange={onOpenChange}
+      isOpen={segment !== null}
+    >
+      {children}
+    </Drawer>
+  );
+};

+ 206 - 0
apps/insights/src/components/PriceFeed/price-components-card.tsx

@@ -0,0 +1,206 @@
+"use client";
+
+import { Card } from "@pythnetwork/component-library/Card";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
+import { type ReactNode, Suspense, useMemo } from "react";
+import { useFilter } from "react-aria";
+
+import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
+
+type Props = {
+  className?: string | undefined;
+  priceComponents: PriceComponent[];
+  nameLoadingSkeleton: ReactNode;
+  scoreLoadingSkeleton: ReactNode;
+  scoreWidth: number;
+  slug: string;
+};
+
+type PriceComponent = {
+  id: string;
+  publisherNameAsString: string | undefined;
+  score: ReactNode;
+  name: ReactNode;
+  uptimeScore: ReactNode;
+  deviationPenalty: ReactNode;
+  deviationScore: ReactNode;
+  stalledPenalty: ReactNode;
+  stalledScore: ReactNode;
+};
+
+export const PriceComponentsCard = ({
+  priceComponents,
+  slug,
+  ...props
+}: Props) => (
+  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
+    <ResolvedPriceComponentsCard
+      priceComponents={priceComponents}
+      slug={slug}
+      {...props}
+    />
+  </Suspense>
+);
+
+const ResolvedPriceComponentsCard = ({
+  priceComponents,
+  slug,
+  ...props
+}: Props) => {
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const {
+    search,
+    page,
+    pageSize,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numResults,
+    numPages,
+    mkPageLink,
+  } = useQueryParamFilterPagination(
+    priceComponents,
+    (priceComponent, search) =>
+      filter.contains(priceComponent.id, search) ||
+      (priceComponent.publisherNameAsString !== undefined &&
+        filter.contains(priceComponent.publisherNameAsString, search)),
+    { defaultPageSize: 20 },
+  );
+
+  const rows = useMemo(
+    () =>
+      paginatedItems.map(({ id, ...data }) => ({
+        id,
+        href: `/price-feeds/${slug}/price-components/${id}`,
+        data,
+      })),
+    [paginatedItems, slug],
+  );
+
+  return (
+    <PriceComponentsCardContents
+      numResults={numResults}
+      search={search}
+      numPages={numPages}
+      page={page}
+      pageSize={pageSize}
+      onSearchChange={updateSearch}
+      onPageSizeChange={updatePageSize}
+      onPageChange={updatePage}
+      mkPageLink={mkPageLink}
+      rows={rows}
+      {...props}
+    />
+  );
+};
+
+type PriceComponentsCardProps = Pick<
+  Props,
+  "className" | "nameLoadingSkeleton" | "scoreLoadingSkeleton" | "scoreWidth"
+> &
+  (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        numResults: number;
+        search: string;
+        numPages: number;
+        page: number;
+        pageSize: number;
+        onSearchChange: (newSearch: string) => void;
+        onPageSizeChange: (newPageSize: number) => void;
+        onPageChange: (newPage: number) => void;
+        mkPageLink: (page: number) => string;
+        rows: RowConfig<
+          | "score"
+          | "name"
+          | "uptimeScore"
+          | "deviationScore"
+          | "deviationPenalty"
+          | "stalledScore"
+          | "stalledPenalty"
+        >[];
+      }
+  );
+
+const PriceComponentsCardContents = ({
+  className,
+  scoreWidth,
+  scoreLoadingSkeleton,
+  nameLoadingSkeleton,
+  ...props
+}: PriceComponentsCardProps) => (
+  <Card
+    className={className}
+    title="Price components"
+    {...(!props.isLoading && {
+      footer: (
+        <Paginator
+          numPages={props.numPages}
+          currentPage={props.page}
+          onPageChange={props.onPageChange}
+          pageSize={props.pageSize}
+          onPageSizeChange={props.onPageSizeChange}
+          pageSizeOptions={[10, 20, 30, 40, 50]}
+          mkPageLink={props.mkPageLink}
+        />
+      ),
+    })}
+  >
+    <Table
+      label="Price components"
+      fill
+      rounded
+      columns={[
+        {
+          id: "score",
+          name: "SCORE",
+          alignment: "center",
+          width: scoreWidth,
+          loadingSkeleton: scoreLoadingSkeleton,
+        },
+        {
+          id: "name",
+          name: "NAME / ID",
+          alignment: "left",
+          isRowHeader: true,
+          fill: true,
+          loadingSkeleton: nameLoadingSkeleton,
+        },
+        {
+          id: "uptimeScore",
+          name: "UPTIME SCORE",
+          alignment: "center",
+          width: 25,
+        },
+        {
+          id: "deviationScore",
+          name: "DERIVATION SCORE",
+          alignment: "center",
+          width: 25,
+        },
+        {
+          id: "deviationPenalty",
+          name: "DERIVATION PENALTY",
+          alignment: "center",
+          width: 25,
+        },
+        {
+          id: "stalledScore",
+          name: "STALLED SCORE",
+          alignment: "center",
+          width: 25,
+        },
+        {
+          id: "stalledPenalty",
+          name: "STALLED PENALTY",
+          alignment: "center",
+          width: 25,
+        },
+      ]}
+      {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
+    />
+  </Card>
+);

+ 8 - 0
apps/insights/src/components/PriceFeed/price-components.module.scss

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

+ 91 - 1
apps/insights/src/components/PriceFeed/price-components.tsx

@@ -1 +1,91 @@
-export const PriceComponents = () => <h1>Price Components</h1>;
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
+import { notFound } from "next/navigation";
+import type { ReactNode } from "react";
+
+import { PriceComponentDrawer } from "./price-component-drawer";
+import { PriceComponentsCard } from "./price-components-card";
+import styles from "./price-components.module.scss";
+import { getRankings } from "../../services/clickhouse";
+import { getData } from "../../services/pyth";
+import { FormattedNumber } from "../FormattedNumber";
+import { PublisherTag } from "../PublisherTag";
+import { Score } from "../Score";
+
+const PUBLISHER_SCORE_WIDTH = 24;
+
+type Props = {
+  children: ReactNode;
+  params: Promise<{
+    slug: string;
+  }>;
+};
+
+export const PriceComponents = async ({ children, params }: Props) => {
+  const { slug } = await params;
+  const symbol = decodeURIComponent(slug);
+  const [data, rankings] = await Promise.all([getData(), getRankings(symbol)]);
+  const feed = data.find((feed) => feed.symbol === symbol);
+
+  return feed ? (
+    <>
+      <PriceComponentsCard
+        slug={slug}
+        priceComponents={rankings.map((ranking) => ({
+          id: ranking.publisher,
+          publisherNameAsString: lookupPublisher(ranking.publisher)?.name,
+          score: (
+            <Score score={ranking.final_score} width={PUBLISHER_SCORE_WIDTH} />
+          ),
+          name: (
+            <div className={styles.publisherName}>
+              <PublisherTag publisherKey={ranking.publisher} />
+              {ranking.cluster === "pythtest-conformance" && (
+                <Badge variant="muted" style="filled" size="xs">
+                  test
+                </Badge>
+              )}
+            </div>
+          ),
+          uptimeScore: (
+            <FormattedNumber
+              value={ranking.uptime_score}
+              maximumSignificantDigits={5}
+            />
+          ),
+          deviationPenalty: ranking.deviation_penalty ? (
+            <FormattedNumber
+              value={ranking.deviation_penalty}
+              maximumSignificantDigits={5}
+            />
+          ) : // eslint-disable-next-line unicorn/no-null
+          null,
+          deviationScore: (
+            <FormattedNumber
+              value={ranking.deviation_score}
+              maximumSignificantDigits={5}
+            />
+          ),
+          stalledPenalty: (
+            <FormattedNumber
+              value={ranking.stalled_penalty}
+              maximumSignificantDigits={5}
+            />
+          ),
+          stalledScore: (
+            <FormattedNumber
+              value={ranking.stalled_score}
+              maximumSignificantDigits={5}
+            />
+          ),
+        }))}
+        nameLoadingSkeleton={<PublisherTag isLoading />}
+        scoreLoadingSkeleton={<Score isLoading width={PUBLISHER_SCORE_WIDTH} />}
+        scoreWidth={PUBLISHER_SCORE_WIDTH}
+      />
+      <PriceComponentDrawer>{children}</PriceComponentDrawer>
+    </>
+  ) : (
+    notFound()
+  );
+};

+ 203 - 0
apps/insights/src/components/PriceFeed/price-feed-select.module.scss

@@ -0,0 +1,203 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceFeedSelect {
+  .trigger {
+    background-color: transparent;
+    border: 1px solid theme.color("button", "outline", "border");
+    outline-offset: 0;
+    outline: theme.spacing(1) solid transparent;
+    cursor: pointer;
+    border-radius: theme.border-radius("xl");
+    padding: theme.spacing(2);
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    gap: theme.spacing(4);
+    transition-property: background-color, color, border-color, outline-color;
+    transition-duration: 100ms;
+    transition-timing-function: linear;
+    text-align: left;
+
+    .caret {
+      width: theme.spacing(8);
+      height: theme.spacing(8);
+      transition: transform 300ms ease;
+    }
+
+    &[data-hovered] {
+      background-color: theme.color("button", "outline", "background", "hover");
+    }
+
+    &[data-pressed] {
+      background-color: theme.color(
+        "button",
+        "outline",
+        "background",
+        "active"
+      );
+    }
+
+    &[data-focus-visible] {
+      border-color: theme.color("focus");
+      outline-color: theme.color("focus-dim");
+    }
+  }
+
+  &[data-open] {
+    .trigger .caret {
+      transform: rotate(-180deg);
+    }
+  }
+}
+
+.popover {
+  min-width: var(--trigger-width);
+  background-color: theme.color("background", "modal");
+  border-radius: theme.border-radius("lg");
+  border: 1px solid theme.color("border");
+  color: theme.color("paragraph");
+  display: flex;
+  font-size: theme.font-size("sm");
+  box-shadow:
+    0 4px 6px -4px rgb(from black r g b / 10%),
+    0 10px 15px -3px rgb(from black r g b / 10%);
+
+  .dialog {
+    display: flex;
+    flex-flow: column nowrap;
+    flex-grow: 1;
+    max-height: theme.spacing(100);
+    outline: none;
+
+    .searchField {
+      flex: 0;
+      z-index: 1;
+
+      .searchInput {
+        width: 100%;
+        padding: theme.spacing(3);
+        border: none;
+        border-top-left-radius: theme.border-radius("lg");
+        border-top-right-radius: theme.border-radius("lg");
+        border-bottom: 1px solid theme.color("border");
+        outline: theme.spacing(1) solid transparent;
+        outline-offset: 0;
+        transition:
+          border-color 100ms linear,
+          outline-color 100ms linear,
+          color 100ms linear;
+
+        &[data-hovered] {
+          border-color: theme.color("forms", "input", "hover", "border");
+        }
+
+        &[data-focused] {
+          border-color: theme.color("focus");
+        }
+
+        &[data-focus-visible] {
+          outline-color: theme.color("focus-dim");
+        }
+
+        &::placeholder {
+          color: theme.color("button", "disabled", "foreground");
+        }
+      }
+    }
+
+    .listbox {
+      outline: none;
+      overflow: auto;
+      width: theme.spacing(110);
+      flex-grow: 1;
+
+      .priceFeed {
+        padding: theme.spacing(3) theme.spacing(4);
+        display: flex;
+        flex-flow: row nowrap;
+        justify-content: space-between;
+        align-items: center;
+        width: 100%;
+        cursor: pointer;
+        transition: background-color 100ms linear;
+        outline: none;
+        text-decoration: none;
+        border-top: 1px solid theme.color("background", "secondary");
+
+        &[data-is-first] {
+          border-top: none;
+        }
+
+        & > *:last-child {
+          flex-shrink: 0;
+        }
+
+        &[data-focused] {
+          background-color: theme.color(
+            "button",
+            "outline",
+            "background",
+            "hover"
+          );
+        }
+
+        &[data-pressed] {
+          background-color: theme.color(
+            "button",
+            "outline",
+            "background",
+            "active"
+          );
+        }
+      }
+    }
+  }
+
+  &[data-placement="top"] {
+    --origin: translateY(8px);
+    --scale: 1, 0.8;
+
+    transform-origin: bottom;
+  }
+
+  &[data-placement="bottom"] {
+    --origin: translateY(-8px);
+    --scale: 1, 0.8;
+
+    transform-origin: top;
+  }
+
+  &[data-placement="right"] {
+    --origin: translateX(-8px);
+    --scale: 0.8, 1;
+
+    transform-origin: left;
+  }
+
+  &[data-placement="left"] {
+    --origin: translateX(8px);
+    --scale: 0.8, 1;
+
+    transform-origin: right;
+  }
+
+  &[data-entering] {
+    animation: popover-slide 200ms;
+  }
+
+  &[data-exiting] {
+    animation: popover-slide 200ms reverse ease-in;
+  }
+}
+
+@keyframes popover-slide {
+  from {
+    transform: scale(var(--scale)) var(--origin);
+    opacity: 0;
+  }
+
+  to {
+    transform: scale(1, 1) translateY(0);
+    opacity: 1;
+  }
+}

+ 102 - 0
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -0,0 +1,102 @@
+"use client";
+
+import { DropdownCaretDown } from "@pythnetwork/component-library/DropdownCaretDown";
+import { type ReactNode, useMemo, useState } from "react";
+import { useCollator, useFilter } from "react-aria";
+import {
+  Select,
+  Button,
+  Popover,
+  Dialog,
+  ListBox,
+  ListBoxItem,
+  UNSTABLE_Virtualizer as Virtualizer,
+  UNSTABLE_ListLayout as ListLayout,
+  TextField,
+  Input,
+} from "react-aria-components";
+
+import styles from "./price-feed-select.module.scss";
+
+type Props = {
+  children: ReactNode;
+  feeds: {
+    id: string;
+    key: string;
+    displaySymbol: string;
+    name: ReactNode;
+    assetClass: ReactNode;
+    assetClassText: string;
+  }[];
+};
+
+export const PriceFeedSelect = ({ children, feeds }: Props) => {
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const [search, setSearch] = useState("");
+  const sortedFeeds = useMemo(
+    () =>
+      feeds.sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol)),
+    [feeds, collator],
+  );
+  const filteredFeeds = useMemo(
+    () =>
+      search === ""
+        ? sortedFeeds
+        : sortedFeeds.filter(
+            (feed) =>
+              filter.contains(feed.displaySymbol, search) ||
+              filter.contains(feed.assetClassText, search) ||
+              filter.contains(feed.key, search),
+          ),
+    [sortedFeeds, search, filter],
+  );
+  return (
+    <Select
+      aria-label="Select a Price Feed"
+      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 ?? ""}>
+          <TextField
+            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"
+            />
+          </TextField>
+          <Virtualizer layout={new ListLayout()}>
+            <ListBox
+              items={filteredFeeds}
+              className={styles.listbox ?? ""}
+              // eslint-disable-next-line jsx-a11y/no-autofocus
+              autoFocus={false}
+            >
+              {({ name, assetClass, id, displaySymbol }) => (
+                <ListBoxItem
+                  textValue={displaySymbol}
+                  className={styles.priceFeed ?? ""}
+                  href={`/price-feeds/${id}`}
+                  data-is-first={id === filteredFeeds[0]?.id ? "" : undefined}
+                >
+                  {name}
+                  {assetClass}
+                </ListBoxItem>
+              )}
+            </ListBox>
+          </Virtualizer>
+        </Dialog>
+      </Popover>
+    </Select>
+  );
+};

+ 20 - 13
apps/insights/src/components/PriceFeedTag/index.tsx

@@ -8,46 +8,53 @@ import styles from "./index.module.scss";
 
 type OwnProps = {
   compact?: boolean | undefined;
-  feed?:
-    | {
+} & (
+  | { isLoading: true }
+  | {
+      isLoading?: false;
+      feed: {
         product: {
           display_symbol: string;
           description: string;
         };
-      }
-    | undefined;
-};
+      };
+    }
+);
+
 type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
 
-export const PriceFeedTag = ({ feed, className, compact, ...props }: Props) => (
+export const PriceFeedTag = ({ className, compact, ...props }: Props) => (
   <div
     className={clsx(styles.priceFeedTag, className)}
     data-compact={compact ? "" : undefined}
-    data-loading={feed === undefined}
+    data-loading={props.isLoading ? "" : undefined}
     {...props}
   >
-    {feed === undefined ? (
+    {props.isLoading ? (
       <Skeleton fill className={styles.icon} />
     ) : (
-      <FeedIcon className={styles.icon} symbol={feed.product.display_symbol} />
+      <FeedIcon
+        className={styles.icon}
+        symbol={props.feed.product.display_symbol}
+      />
     )}
     <div className={styles.nameAndDescription}>
-      {feed === undefined ? (
+      {props.isLoading ? (
         <div className={styles.name}>
           <Skeleton width={30} />
         </div>
       ) : (
         <FeedName
           className={styles.name}
-          symbol={feed.product.display_symbol}
+          symbol={props.feed.product.display_symbol}
         />
       )}
       {!compact && (
         <div className={styles.description}>
-          {feed === undefined ? (
+          {props.isLoading ? (
             <Skeleton width={50} />
           ) : (
-            feed.product.description.split("/")[0]
+            props.feed.product.description.split("/")[0]
           )}
         </div>
       )}

+ 42 - 26
apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { useLogger } from "@pythnetwork/app-logger";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import {
   CLOSE_DURATION_IN_MS,
@@ -8,11 +9,15 @@ import {
 } from "@pythnetwork/component-library/Drawer";
 import { Table } from "@pythnetwork/component-library/Table";
 import { usePathname } from "next/navigation";
+import {
+  parseAsString,
+  parseAsInteger,
+  useQueryStates,
+  createSerializer,
+} from "nuqs";
 import { type ReactNode, useMemo } from "react";
 import { useCollator } from "react-aria";
 
-import { serialize, useQueryParams } from "./query-params";
-
 type Props = {
   numFeedsByAssetClass: Record<string, number>;
   children: ReactNode;
@@ -38,10 +43,10 @@ export const AssetClassesDrawer = ({
           </>
         }
       >
-        {({ close }) => (
+        {({ state }) => (
           <AssetClassTable
             numFeedsByAssetClass={numFeedsByAssetClass}
-            closeDrawer={close}
+            state={state}
           />
         )}
       </Drawer>
@@ -51,48 +56,59 @@ export const AssetClassesDrawer = ({
 
 type AssetClassTableProps = {
   numFeedsByAssetClass: Record<string, number>;
-  closeDrawer: () => void;
+  state: { close: () => void };
 };
 
 const AssetClassTable = ({
   numFeedsByAssetClass,
-  closeDrawer,
+  state,
 }: AssetClassTableProps) => {
+  const logger = useLogger();
   const collator = useCollator();
   const pathname = usePathname();
-  const { updateAssetClass, updateSearch } = useQueryParams();
+  const queryStates = {
+    page: parseAsInteger.withDefault(1),
+    search: parseAsString.withDefault(""),
+    assetClass: parseAsString.withDefault(""),
+  };
+  const serialize = createSerializer(queryStates);
+  const [, setQuery] = useQueryStates(queryStates);
   const assetClassRows = useMemo(
     () =>
       Object.entries(numFeedsByAssetClass)
         .sort(([a], [b]) => collator.compare(a, b))
-        .map(([assetClass, count]) => ({
-          id: assetClass,
-          href: `${pathname}${serialize({ assetClass })}`,
-          onAction: () => {
-            closeDrawer();
-            setTimeout(() => {
-              updateAssetClass(assetClass);
-              updateSearch("");
-            }, CLOSE_DURATION_IN_MS);
-          },
-          data: {
-            assetClass,
-            count: <Badge style="outline">{count}</Badge>,
-          },
-        })),
+        .map(([assetClass, count]) => {
+          const newQuery = { assetClass, search: "", page: 1 };
+          return {
+            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);
+            },
+            data: {
+              assetClass,
+              count: <Badge style="outline">{count}</Badge>,
+            },
+          };
+        }),
     [
       numFeedsByAssetClass,
       collator,
-      closeDrawer,
+      state,
       pathname,
-      updateAssetClass,
-      updateSearch,
+      setQuery,
+      serialize,
+      logger,
     ],
   );
   return (
     <Table
       fill
-      divide
       label="Asset Classes"
       columns={[
         {

+ 0 - 1
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -94,7 +94,6 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
       </div>
       <Table
         fill
-        divide
         label="Asset Classes"
         className={styles.priceFeeds ?? ""}
         columns={[

+ 3 - 29
apps/insights/src/components/PriceFeeds/index.tsx

@@ -12,13 +12,12 @@ import {
 import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { type ElementType } from "react";
-import { z } from "zod";
 
 import { AssetClassesDrawer } from "./asset-classes-drawer";
 import { ComingSoonList } from "./coming-soon-list";
 import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
-import { client } from "../../services/pyth";
+import { getData } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
 import { FeedKey } from "../FeedKey";
@@ -123,7 +122,7 @@ export const PriceFeeds = async () => {
         />
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
-          nameLoadingSkeleton={<PriceFeedTag compact />}
+          nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
           priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
             symbol: feed.symbol,
             id: feed.product.price_account,
@@ -203,14 +202,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
 );
 
 const getPriceFeeds = async () => {
-  const data = await client.getData();
-  const priceFeeds = priceFeedsSchema.parse(
-    data.symbols.map((symbol) => ({
-      symbol,
-      product: data.productFromSymbol.get(symbol),
-      price: data.productPrice.get(symbol),
-    })),
-  );
+  const priceFeeds = await getData();
   const activeFeeds = priceFeeds.filter((feed) => isActive(feed));
   const comingSoon = priceFeeds.filter((feed) => !isActive(feed));
   return { activeFeeds, comingSoon };
@@ -243,24 +235,6 @@ const filterFeeds = <T extends { symbol: string }>(
 const isActive = (feed: { price: { minPublishers: number } }) =>
   feed.price.minPublishers <= 50;
 
-const priceFeedsSchema = z.array(
-  z.object({
-    symbol: z.string(),
-    product: z.object({
-      display_symbol: z.string(),
-      asset_type: z.string(),
-      description: z.string(),
-      price_account: z.string(),
-      weekly_schedule: z.string().optional(),
-    }),
-    price: z.object({
-      exponent: z.number(),
-      numQuoters: z.number(),
-      minPublishers: z.number(),
-    }),
-  }),
-);
-
 class NoSuchFeedError extends Error {
   constructor(symbol: string) {
     super(`No feed exists named ${symbol}`);

+ 21 - 3
apps/insights/src/components/PriceFeeds/layout.tsx

@@ -13,11 +13,11 @@ export const PriceFeedsLayout = ({ children }: Props) => (
     variants={{
       initial: (custom) => ({
         opacity: 0,
-        scale: isGoingToIndex(custom) ? 1.04 : 0.96,
+        scale: getInitialScale(custom),
       }),
       exit: (custom) => ({
         opacity: 0,
-        scale: isGoingToIndex(custom) ? 0.96 : 1.04,
+        scale: getExitScale(custom),
         transition: {
           scale: { type: "spring", bounce: 0 },
         },
@@ -38,4 +38,22 @@ export const PriceFeedsLayout = ({ children }: Props) => (
   </LayoutTransition>
 );
 
-const isGoingToIndex = ({ segment }: VariantArg) => segment === null;
+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;
+  }
+};

+ 44 - 43
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -1,17 +1,18 @@
 "use client";
 
 import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
+import { useLogger } from "@pythnetwork/app-logger";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
-import { usePathname } from "next/navigation";
+import { useQueryState, parseAsString } from "nuqs";
 import { type ReactNode, Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
-import { serialize, useQueryParams } from "./query-params";
+import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { SKELETON_WIDTH } from "../LivePrices";
 
 type Props = {
@@ -41,70 +42,70 @@ export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
 );
 
 const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
-  const {
-    search,
-    page,
-    pageSize,
-    assetClass,
-    updateSearch,
-    updatePage,
-    updatePageSize,
-    updateAssetClass,
-  } = useQueryParams();
-
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const logger = useLogger();
   const collator = useCollator();
-  const sortedFeeds = useMemo(
+  const sortedPriceFeeds = useMemo(
     () =>
       priceFeeds.sort((a, b) =>
         collator.compare(a.displaySymbol, b.displaySymbol),
       ),
     [priceFeeds, collator],
   );
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const [assetClass, setAssetClass] = useQueryState(
+    "assetClass",
+    parseAsString.withDefault(""),
+  );
   const feedsFilteredByAssetClass = useMemo(
     () =>
       assetClass
-        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
-        : sortedFeeds,
-    [assetClass, sortedFeeds],
+        ? sortedPriceFeeds.filter(
+            (feed) => feed.assetClassAsString === assetClass,
+          )
+        : sortedPriceFeeds,
+    [assetClass, sortedPriceFeeds],
   );
-  const filteredFeeds = useMemo(() => {
-    if (search === "") {
-      return feedsFilteredByAssetClass;
-    } else {
+  const {
+    search,
+    page,
+    pageSize,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numResults,
+    numPages,
+    mkPageLink,
+  } = useQueryParamFilterPagination(
+    feedsFilteredByAssetClass,
+    (priceFeed, search) => {
       const searchTokens = search
         .split(" ")
         .flatMap((item) => item.split(","))
         .filter(Boolean);
-      return feedsFilteredByAssetClass.filter((feed) =>
-        searchTokens.some((token) => filter.contains(feed.symbol, token)),
+      return searchTokens.some((token) =>
+        filter.contains(priceFeed.symbol, token),
       );
-    }
-  }, [search, feedsFilteredByAssetClass, filter]);
-  const paginatedFeeds = useMemo(
-    () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize),
-    [page, pageSize, filteredFeeds],
+    },
   );
   const rows = useMemo(
     () =>
-      paginatedFeeds.map(({ id, symbol, ...data }) => ({
+      paginatedItems.map(({ id, symbol, ...data }) => ({
         id,
         href: `/price-feeds/${encodeURIComponent(symbol)}`,
         data,
       })),
-    [paginatedFeeds],
+    [paginatedItems],
   );
 
-  const numPages = useMemo(
-    () => Math.ceil(filteredFeeds.length / pageSize),
-    [filteredFeeds.length, pageSize],
-  );
-
-  const pathname = usePathname();
-
-  const mkPageLink = useCallback(
-    (page: number) => `${pathname}${serialize({ page, pageSize })}`,
-    [pathname, pageSize],
+  const updateAssetClass = useCallback(
+    (newAssetClass: string) => {
+      updatePage(1);
+      setAssetClass(newAssetClass).catch((error: unknown) => {
+        logger.error("Failed to update asset class", error);
+      });
+    },
+    [updatePage, setAssetClass, logger],
   );
 
   const assetClasses = useMemo(
@@ -117,7 +118,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   return (
     <PriceFeedsCardContents
-      numResults={filteredFeeds.length}
+      numResults={numResults}
       search={search}
       assetClass={assetClass}
       assetClasses={assetClasses}
@@ -211,7 +212,7 @@ const PriceFeedsCardContents = ({
           {...(props.isLoading
             ? { isPending: true, isDisabled: true }
             : {
-                defaultValue: props.search,
+                value: props.search,
                 onChange: props.onSearchChange,
               })}
         />

+ 0 - 72
apps/insights/src/components/PriceFeeds/query-params.ts

@@ -1,72 +0,0 @@
-import { useLogger } from "@pythnetwork/app-logger";
-import {
-  parseAsString,
-  parseAsInteger,
-  useQueryStates,
-  createSerializer,
-} from "nuqs";
-import { useCallback } from "react";
-
-const queryParams = {
-  assetClass: parseAsString.withDefault(""),
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(30),
-  search: parseAsString.withDefault(""),
-};
-
-export const serialize = createSerializer(queryParams);
-
-export const useQueryParams = () => {
-  const logger = useLogger();
-
-  const [{ search, page, pageSize, assetClass }, setQuery] =
-    useQueryStates(queryParams);
-
-  const updateQuery = useCallback(
-    (...params: Parameters<typeof setQuery>) => {
-      setQuery(...params).catch((error: unknown) => {
-        logger.error("Failed to update query", error);
-      });
-    },
-    [setQuery, logger],
-  );
-
-  const updateSearch = useCallback(
-    (newSearch: string) => {
-      updateQuery({ page: 1, search: newSearch });
-    },
-    [updateQuery],
-  );
-
-  const updatePage = useCallback(
-    (newPage: number) => {
-      updateQuery({ page: newPage });
-    },
-    [updateQuery],
-  );
-
-  const updatePageSize = useCallback(
-    (newPageSize: number) => {
-      updateQuery({ page: 1, pageSize: newPageSize });
-    },
-    [updateQuery],
-  );
-
-  const updateAssetClass = useCallback(
-    (newAssetClass: string) => {
-      updateQuery({ page: 1, assetClass: newAssetClass });
-    },
-    [updateQuery],
-  );
-
-  return {
-    search,
-    page,
-    pageSize,
-    assetClass,
-    updateSearch,
-    updatePage,
-    updatePageSize,
-    updateAssetClass,
-  };
-};

+ 52 - 0
apps/insights/src/components/PublisherTag/index.module.scss

@@ -0,0 +1,52 @@
+@use "@pythnetwork/component-library/theme";
+
+.publisherTag {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(4);
+  align-items: center;
+
+  .icon {
+    width: theme.spacing(9);
+    height: theme.spacing(9);
+  }
+
+  .key {
+    margin: 0 -#{theme.button-padding("sm", true)};
+  }
+
+  .nameAndKey {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(1);
+    align-items: flex-start;
+
+    .name {
+      color: theme.color("heading");
+    }
+
+    .key {
+      margin: -#{theme.spacing(1)} -#{theme.button-padding("xs", true)};
+      margin-bottom: -#{theme.spacing(2)};
+    }
+  }
+
+  .undisclosedIconWrapper {
+    background: theme.color("button", "disabled", "background");
+    border-radius: theme.border-radius("full");
+    display: grid;
+    place-content: center;
+
+    .undisclosedIcon {
+      width: theme.spacing(4);
+      height: theme.spacing(4);
+      color: theme.color("button", "disabled", "foreground");
+    }
+  }
+
+  &[data-loading] {
+    .icon {
+      border-radius: theme.border-radius("full");
+    }
+  }
+}

+ 64 - 0
apps/insights/src/components/PublisherTag/index.tsx

@@ -0,0 +1,64 @@
+import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
+import clsx from "clsx";
+import { type ComponentProps, useMemo } from "react";
+
+import styles from "./index.module.scss";
+import { CopyButton } from "../CopyButton";
+
+type Props = { isLoading: true } | { isLoading?: false; publisherKey: string };
+
+export const PublisherTag = (props: Props) => {
+  const knownPublisher = useMemo(
+    () => (props.isLoading ? undefined : lookupPublisher(props.publisherKey)),
+    [props],
+  );
+  const Icon = knownPublisher?.icon.color ?? UndisclosedIcon;
+  return (
+    <div
+      data-loading={props.isLoading ? "" : undefined}
+      className={styles.publisherTag}
+    >
+      {props.isLoading ? (
+        <Skeleton fill className={styles.icon} />
+      ) : (
+        <Icon className={styles.icon} />
+      )}
+      {props.isLoading ? (
+        <Skeleton width={30} />
+      ) : (
+        <>
+          {knownPublisher ? (
+            <div className={styles.nameAndKey}>
+              <div className={styles.name}>{knownPublisher.name}</div>
+              <CopyButton
+                size="xs"
+                variant="ghost"
+                className={styles.key ?? ""}
+                text={props.publisherKey}
+              >
+                {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`}
+              </CopyButton>
+            </div>
+          ) : (
+            <CopyButton
+              size="sm"
+              variant="ghost"
+              className={styles.key ?? ""}
+              text={props.publisherKey}
+            >
+              {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`}
+            </CopyButton>
+          )}
+        </>
+      )}
+    </div>
+  );
+};
+
+const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
+  <div className={clsx(styles.undisclosedIconWrapper, className)} {...props}>
+    <Broadcast className={styles.undisclosedIcon} />
+  </div>
+);

+ 0 - 50
apps/insights/src/components/Publishers/index.module.scss

@@ -133,53 +133,3 @@
     theme.pallette-color("steel", 700)
   );
 }
-
-.publisherName {
-  display: flex;
-  flex-flow: row nowrap;
-  gap: theme.spacing(4);
-  align-items: center;
-
-  .publisherIcon {
-    width: theme.spacing(9);
-    height: theme.spacing(9);
-  }
-
-  &.publisherNamePlaceholder .publisherIcon {
-    border-radius: theme.border-radius("full");
-  }
-
-  .key {
-    margin: 0 -#{theme.button-padding("sm", true)};
-  }
-
-  .nameAndKey {
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(1);
-    align-items: flex-start;
-
-    .name {
-      color: theme.color("heading");
-    }
-
-    .key {
-      margin: 0 -#{theme.button-padding("xs", true)};
-    }
-  }
-
-  &[data-is-undisclosed] {
-    .undisclosedIconWrapper {
-      background: theme.color("button", "disabled", "background");
-      border-radius: theme.border-radius("full");
-      display: grid;
-      place-content: center;
-
-      .undisclosedIcon {
-        width: theme.spacing(4);
-        height: theme.spacing(4);
-        color: theme.color("button", "disabled", "foreground");
-      }
-    }
-  }
-}

+ 19 - 72
apps/insights/src/components/Publishers/index.tsx

@@ -1,9 +1,8 @@
 import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
-import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 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 { ButtonLink, Button } from "@pythnetwork/component-library/Button";
+import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
@@ -17,13 +16,15 @@ import { PublishersCard } from "./publishers-card";
 import { SemicircleMeter, Label } from "./semicircle-meter";
 import { client as clickhouseClient } from "../../services/clickhouse";
 import { client as hermesClient } from "../../services/hermes";
-import { CLUSTER, client as pythClient } from "../../services/pyth";
+import { CLUSTER, getData } from "../../services/pyth";
 import { client as stakingClient } from "../../services/staking";
-import { CopyButton } from "../CopyButton";
 import { FormattedTokens } from "../FormattedTokens";
+import { PublisherTag } from "../PublisherTag";
+import { Score } from "../Score";
 import { TokenIcon } from "../TokenIcon";
 
 const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n;
+const PUBLISHER_SCORE_WIDTH = 24;
 
 export const Publishers = async () => {
   const [publishers, totalFeeds, oisStats] = await Promise.all([
@@ -69,14 +70,14 @@ export const Publishers = async () => {
                     <b>Median Scores</b> of all publishers who contribute to the
                     Pyth Network.
                   </p>
-                  <ButtonLink
+                  <Button
                     size="xs"
                     variant="solid"
                     href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
                     target="_blank"
                   >
                     Learn more
-                  </ButtonLink>
+                  </Button>
                 </Alert>
               </AlertTrigger>
             }
@@ -91,7 +92,7 @@ export const Publishers = async () => {
             title="Oracle Integrity Staking (OIS)"
             className={styles.oisCard}
             toolbar={
-              <ButtonLink
+              <Button
                 href="https://staking.pyth.network"
                 target="_blank"
                 size="sm"
@@ -99,7 +100,7 @@ export const Publishers = async () => {
                 afterIcon={ArrowSquareOut}
               >
                 Staking App
-              </ButtonLink>
+              </Button>
             }
           >
             <SemicircleMeter
@@ -158,31 +159,22 @@ export const Publishers = async () => {
           rankingLoadingSkeleton={
             <Skeleton className={styles.rankingLoader} fill />
           }
-          nameLoadingSkeleton={
-            <div
-              className={clsx(
-                styles.publisherName,
-                styles.publisherNamePlaceholder,
-              )}
-            >
-              <Skeleton className={styles.publisherIcon} fill />
-              <div className={styles.nameAndKey}>
-                <div className={styles.name}>
-                  <Skeleton width={40} />
-                </div>
-                <Skeleton className={styles.publisherKey ?? ""} width={20} />
-              </div>
-            </div>
+          nameLoadingSkeleton={<PublisherTag isLoading />}
+          scoreLoadingSkeleton={
+            <Score isLoading width={PUBLISHER_SCORE_WIDTH} />
           }
+          scoreWidth={PUBLISHER_SCORE_WIDTH}
           publishers={publishers.map(
             ({ key, rank, numSymbols, medianScore }) => ({
               id: key,
               nameAsString: lookupPublisher(key)?.name,
-              name: <PublisherName publisherKey={key} />,
+              name: <PublisherTag publisherKey={key} />,
               ranking: <Ranking>{rank}</Ranking>,
               activeFeeds: numSymbols,
               inactiveFeeds: totalFeeds - numSymbols,
-              medianScore,
+              medianScore: (
+                <Score score={medianScore} width={PUBLISHER_SCORE_WIDTH} />
+              ),
             }),
           )}
         />
@@ -195,48 +187,6 @@ const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
   <span className={clsx(styles.ranking, className)} {...props} />
 );
 
-const PublisherName = ({ publisherKey }: { publisherKey: string }) => {
-  const knownPublisher = lookupPublisher(publisherKey);
-  const Icon = knownPublisher?.icon.color ?? UndisclosedIcon;
-  const name = knownPublisher?.name ?? "Undisclosed";
-  return (
-    <div
-      data-is-undisclosed={knownPublisher === undefined ? "" : undefined}
-      className={styles.publisherName}
-    >
-      <Icon className={styles.publisherIcon} />
-      {knownPublisher ? (
-        <div className={styles.nameAndKey}>
-          <div className={styles.name}>{name}</div>
-          <CopyButton
-            size="xs"
-            variant="ghost"
-            className={styles.key ?? ""}
-            text={publisherKey}
-          >
-            {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`}
-          </CopyButton>
-        </div>
-      ) : (
-        <CopyButton
-          size="sm"
-          variant="ghost"
-          className={styles.key ?? ""}
-          text={publisherKey}
-        >
-          {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`}
-        </CopyButton>
-      )}
-    </div>
-  );
-};
-
-const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
-  <div className={clsx(styles.undisclosedIconWrapper, className)} {...props}>
-    <Broadcast className={styles.undisclosedIcon} />
-  </div>
-);
-
 const getPublishers = async () => {
   const rows = await clickhouseClient.query({
     query:
@@ -258,11 +208,8 @@ const publishersSchema = z.array(
 );
 
 const getTotalFeedCount = async () => {
-  const pythData = await pythClient.getData();
-  return pythData.symbols.filter(
-    (symbol) =>
-      (pythData.productPrice.get(symbol)?.numComponentPrices ?? 0) > 0,
-  ).length;
+  const pythData = await getData();
+  return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
 };
 
 const getOisStats = async () => {

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

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

+ 37 - 156
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -1,39 +1,22 @@
 "use client";
 
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
-import { useLogger } from "@pythnetwork/app-logger";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
-import clsx from "clsx";
-import { usePathname } from "next/navigation";
-import {
-  parseAsString,
-  parseAsInteger,
-  useQueryStates,
-  createSerializer,
-} from "nuqs";
-import {
-  type ReactNode,
-  type CSSProperties,
-  Suspense,
-  useCallback,
-  useMemo,
-} from "react";
+import { type ReactNode, Suspense, useMemo } from "react";
 import { useFilter } from "react-aria";
-import { Meter } from "react-aria-components";
 
-import styles from "./publishers-card.module.scss";
-
-const PUBLISHER_SCORE_WIDTH = 24;
+import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 
 type Props = {
   className?: string | undefined;
   rankingLoadingSkeleton: ReactNode;
   nameLoadingSkeleton: ReactNode;
+  scoreLoadingSkeleton: ReactNode;
+  scoreWidth: number;
   publishers: Publisher[];
 };
 
@@ -44,7 +27,7 @@ type Publisher = {
   ranking: ReactNode;
   activeFeeds: ReactNode;
   inactiveFeeds: ReactNode;
-  medianScore: number;
+  medianScore: ReactNode;
 };
 
 export const PublishersCard = ({ publishers, ...props }: Props) => (
@@ -54,89 +37,34 @@ export const PublishersCard = ({ publishers, ...props }: Props) => (
 );
 
 const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
-  const logger = useLogger();
-
-  const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams);
-
-  const updateQuery = useCallback(
-    (...params: Parameters<typeof setQuery>) => {
-      setQuery(...params).catch((error: unknown) => {
-        logger.error("Failed to update query", error);
-      });
-    },
-    [setQuery, logger],
-  );
-
-  const updateSearch = useCallback(
-    (newSearch: string) => {
-      updateQuery({ page: 1, search: newSearch });
-    },
-    [updateQuery],
-  );
-
-  const updatePage = useCallback(
-    (newPage: number) => {
-      updateQuery({ page: newPage });
-    },
-    [updateQuery],
-  );
-
-  const updatePageSize = useCallback(
-    (newPageSize: number) => {
-      updateQuery({ page: 1, pageSize: newPageSize });
-    },
-    [updateQuery],
-  );
-
   const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const filteredPublishers = useMemo(
-    () =>
-      search === ""
-        ? publishers
-        : publishers.filter(
-            (publisher) =>
-              filter.contains(publisher.id, search) ||
-              (publisher.nameAsString !== undefined &&
-                filter.contains(publisher.nameAsString, search)),
-          ),
-    [publishers, search, filter],
-  );
-  const paginatedPublishers = useMemo(
-    () => filteredPublishers.slice((page - 1) * pageSize, page * pageSize),
-    [page, pageSize, filteredPublishers],
-  );
-
-  const numPages = useMemo(
-    () => Math.ceil(filteredPublishers.length / pageSize),
-    [filteredPublishers.length, pageSize],
-  );
-
-  const pathname = usePathname();
-
-  const mkPageLink = useCallback(
-    (page: number) => {
-      const serialize = createSerializer(queryParams);
-      return `${pathname}${serialize({ page, pageSize })}`;
-    },
-    [pathname, pageSize],
+  const {
+    search,
+    page,
+    pageSize,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numResults,
+    numPages,
+    mkPageLink,
+  } = useQueryParamFilterPagination(
+    publishers,
+    (publisher, search) =>
+      filter.contains(publisher.id, search) ||
+      (publisher.nameAsString !== undefined &&
+        filter.contains(publisher.nameAsString, search)),
   );
 
   const rows = useMemo(
-    () =>
-      paginatedPublishers.map(({ id, medianScore, ...data }) => ({
-        id,
-        href: "#",
-        data: {
-          ...data,
-          medianScore: <PublisherScore score={medianScore} />,
-        },
-      })),
-    [paginatedPublishers],
+    () => paginatedItems.map(({ id, ...data }) => ({ id, href: "#", data })),
+    [paginatedItems],
   );
 
   return (
     <PublishersCardContents
-      numResults={filteredPublishers.length}
+      numResults={numResults}
       search={search}
       numPages={numPages}
       page={page}
@@ -151,15 +79,13 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
   );
 };
 
-const queryParams = {
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(30),
-  search: parseAsString.withDefault(""),
-};
-
 type PublishersCardContentsProps = Pick<
   Props,
-  "className" | "rankingLoadingSkeleton" | "nameLoadingSkeleton"
+  | "className"
+  | "rankingLoadingSkeleton"
+  | "nameLoadingSkeleton"
+  | "scoreLoadingSkeleton"
+  | "scoreWidth"
 > &
   (
     | { isLoading: true }
@@ -184,10 +110,12 @@ const PublishersCardContents = ({
   className,
   rankingLoadingSkeleton,
   nameLoadingSkeleton,
+  scoreLoadingSkeleton,
+  scoreWidth,
   ...props
 }: PublishersCardContentsProps) => (
   <Card
-    className={clsx(styles.publishersCard, className)}
+    className={className}
     icon={<Broadcast />}
     title={
       <>
@@ -206,7 +134,7 @@ const PublishersCardContents = ({
         {...(props.isLoading
           ? { isPending: true, isDisabled: true }
           : {
-              defaultValue: props.search,
+              value: props.search,
               onChange: props.onSearchChange,
             })}
       />
@@ -259,15 +187,9 @@ const PublishersCardContents = ({
         {
           id: "medianScore",
           name: "MEDIAN SCORE",
-          width: PUBLISHER_SCORE_WIDTH,
-          alignment: "center",
-          loadingSkeleton: (
-            <Skeleton
-              className={styles.publisherScore}
-              fill
-              style={{ "--width": PUBLISHER_SCORE_WIDTH } as CSSProperties}
-            />
-          ),
+          alignment: "right",
+          width: scoreWidth,
+          loadingSkeleton: scoreLoadingSkeleton,
         },
       ]}
       {...(props.isLoading
@@ -281,44 +203,3 @@ const PublishersCardContents = ({
     />
   </Card>
 );
-
-type PublisherScoreProps = {
-  score: number;
-};
-
-const PublisherScore = ({ score }: PublisherScoreProps) => (
-  <Meter
-    value={score}
-    maxValue={1}
-    style={{ "--width": PUBLISHER_SCORE_WIDTH } as CSSProperties}
-    aria-label="Score"
-  >
-    {({ percentage }) => (
-      <div
-        className={styles.publisherScore}
-        data-size-class={getSizeClass(percentage)}
-      >
-        <div
-          className={styles.fill}
-          style={{ width: `${(50 + percentage / 2).toString()}%` }}
-        >
-          {score.toFixed(2)}
-        </div>
-      </div>
-    )}
-  </Meter>
-);
-
-const getSizeClass = (percentage: number) => {
-  if (percentage < 60) {
-    return "bad";
-  } else if (percentage < 70) {
-    return "weak";
-  } else if (percentage < 80) {
-    return "warn";
-  } else if (percentage < 90) {
-    return "ok";
-  } else {
-    return "good";
-  }
-};

+ 13 - 7
apps/insights/src/components/Root/footer.tsx

@@ -3,9 +3,12 @@ import { GithubLogo } from "@phosphor-icons/react/dist/ssr/GithubLogo";
 import { TelegramLogo } from "@phosphor-icons/react/dist/ssr/TelegramLogo";
 import { XLogo } from "@phosphor-icons/react/dist/ssr/XLogo";
 import { YoutubeLogo } from "@phosphor-icons/react/dist/ssr/YoutubeLogo";
-import { ButtonLink } from "@pythnetwork/component-library/Button";
+import {
+  type Props as ButtonProps,
+  Button,
+} from "@pythnetwork/component-library/Button";
 import { Link } from "@pythnetwork/component-library/Link";
-import type { ComponentProps } from "react";
+import type { ComponentProps, ElementType } from "react";
 
 import styles from "./footer.module.scss";
 import Wordmark from "./wordmark.svg";
@@ -64,15 +67,18 @@ export const Footer = () => (
   </footer>
 );
 
-type SocialLinkProps = Omit<
-  ComponentProps<typeof ButtonLink>,
+type SocialLinkProps<T extends ElementType> = Omit<
+  ButtonProps<T>,
   "target" | "variant" | "size" | "beforeIcon" | "hideText"
 > & {
-  icon: ComponentProps<typeof ButtonLink>["beforeIcon"];
+  icon: ComponentProps<typeof Button>["beforeIcon"];
 };
 
-const SocialLink = ({ icon, ...props }: SocialLinkProps) => (
-  <ButtonLink
+const SocialLink = <T extends ElementType>({
+  icon,
+  ...props
+}: SocialLinkProps<T>) => (
+  <Button
     target="_blank"
     variant="ghost"
     size="sm"

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

@@ -1,5 +1,5 @@
 import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
-import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
+import { Button } from "@pythnetwork/component-library/Button";
 import { Link } from "@pythnetwork/component-library/Link";
 import clsx from "clsx";
 import type { ComponentProps } from "react";
@@ -28,14 +28,14 @@ export const Header = ({ className, ...props }: ComponentProps<"header">) => (
           Support
         </Button>
         <SearchButton />
-        <ButtonLink
+        <Button
           href="https://docs.pyth.network"
           size="sm"
           rounded
           target="_blank"
         >
           Dev Docs
-        </ButtonLink>
+        </Button>
         <ThemeSwitch className={styles.themeSwitch ?? ""} />
       </div>
     </div>

+ 11 - 6
apps/insights/src/components/Root/theme-switch.tsx

@@ -4,12 +4,15 @@ import type { IconProps } from "@phosphor-icons/react";
 import { Desktop } from "@phosphor-icons/react/dist/ssr/Desktop";
 import { Moon } from "@phosphor-icons/react/dist/ssr/Moon";
 import { Sun } from "@phosphor-icons/react/dist/ssr/Sun";
-import { Button } from "@pythnetwork/component-library/Button";
+import {
+  type Props as ButtonProps,
+  Button,
+} from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
 import { motion } from "motion/react";
 import { useTheme } from "next-themes";
 import {
-  type ComponentProps,
+  type ElementType,
   useCallback,
   useRef,
   useMemo,
@@ -19,12 +22,15 @@ import { useIsSSR } from "react-aria";
 
 import styles from "./theme-switch.module.scss";
 
-type Props = Omit<
-  ComponentProps<typeof Button>,
+type Props<T extends ElementType> = Omit<
+  ButtonProps<T>,
   "beforeIcon" | "variant" | "size" | "hideText" | "children" | "onPress"
 >;
 
-export const ThemeSwitch = ({ className, ...props }: Props) => {
+export const ThemeSwitch = <T extends ElementType>({
+  className,
+  ...props
+}: Props<T>) => {
   const { theme, setTheme } = useTheme();
 
   const toggleTheme = useCallback(() => {
@@ -70,7 +76,6 @@ type IconMovementProps = Omit<IconProps, "offset"> & {
 
 const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => (
   <motion.div
-    // @ts-expect-error Looks like framer-motion has a bug in it's typings...
     className={styles.iconMovement}
     animate={{ offsetDistance: offset }}
     transition={{ type: "spring", bounce: 0.35, duration: 0.6 }}

+ 66 - 0
apps/insights/src/components/Score/index.module.scss

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

+ 63 - 0
apps/insights/src/components/Score/index.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import type { CSSProperties } from "react";
+import { Meter } from "react-aria-components";
+
+import styles from "./index.module.scss";
+
+const SCORE_WIDTH = 24;
+
+type Props = {
+  width?: number | undefined;
+} & (
+  | { isLoading: true }
+  | {
+      isLoading?: false;
+      score: number;
+    }
+);
+
+export const Score = ({ width, ...props }: Props) =>
+  props.isLoading ? (
+    <Skeleton
+      className={styles.score}
+      fill
+      style={{ "--width": width ?? SCORE_WIDTH } as CSSProperties}
+    />
+  ) : (
+    <Meter
+      value={props.score}
+      maxValue={1}
+      style={{ "--width": width ?? SCORE_WIDTH } as CSSProperties}
+      aria-label="Score"
+    >
+      {({ percentage }) => (
+        <div
+          className={styles.score}
+          data-size-class={getSizeClass(percentage)}
+        >
+          <div
+            className={styles.fill}
+            style={{ width: `${(50 + percentage / 2).toString()}%` }}
+          >
+            {props.score.toFixed(2)}
+          </div>
+        </div>
+      )}
+    </Meter>
+  );
+
+const getSizeClass = (percentage: number) => {
+  if (percentage < 60) {
+    return "bad";
+  } else if (percentage < 70) {
+    return "weak";
+  } else if (percentage < 80) {
+    return "warn";
+  } else if (percentage < 90) {
+    return "ok";
+  } else {
+    return "good";
+  }
+};

+ 7 - 0
apps/insights/src/hex.ts

@@ -0,0 +1,7 @@
+import base58 from "bs58";
+
+export const toHex = (value: string) =>
+  `0x${Array.from(base58.decode(value), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+
+export const truncateHex = (value: string) =>
+  `${value.slice(0, 6)}...${value.slice(-4)}`;

+ 42 - 0
apps/insights/src/services/clickhouse.ts

@@ -1,7 +1,49 @@
 import "server-only";
 
 import { createClient } from "@clickhouse/client";
+import { z } from "zod";
 
+import { cache } from "../cache";
 import { CLICKHOUSE } from "../config/server";
 
 export const client = createClient(CLICKHOUSE);
+
+export const getRankings = cache(async (symbol: string) => {
+  const rows = await client.query({
+    query: `
+      SELECT
+        cluster,
+        publisher,
+        uptime_score,
+        uptime_rank,
+        deviation_penalty,
+        deviation_score,
+        deviation_rank,
+        stalled_penalty,
+        stalled_score,
+        stalled_rank,
+        final_score
+      FROM insights_feed_component_rankings(symbol={symbol: String})
+    `,
+    query_params: { symbol },
+  });
+  const result = await rows.json();
+
+  return rankingsSchema.parse(result.data);
+});
+
+const rankingsSchema = z.array(
+  z.strictObject({
+    cluster: z.enum(["pythnet", "pythtest-conformance"]),
+    publisher: z.string(),
+    uptime_score: z.number(),
+    uptime_rank: z.number(),
+    deviation_penalty: z.number().nullable(),
+    deviation_score: z.number(),
+    deviation_rank: z.number(),
+    stalled_penalty: z.number(),
+    stalled_score: z.number(),
+    stalled_rank: z.number(),
+    final_score: z.number(),
+  }),
+);

+ 45 - 0
apps/insights/src/services/pyth.ts

@@ -6,12 +6,57 @@ import {
 } from "@pythnetwork/client";
 import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection";
 import { Connection, PublicKey } from "@solana/web3.js";
+import { z } from "zod";
+
+import { cache } from "../cache";
 
 export const CLUSTER = "pythnet";
 const connection = new Connection(getPythClusterApiUrl(CLUSTER));
 const programKey = getPythProgramKeyForCluster(CLUSTER);
 export const client = new PythHttpClient(connection, programKey);
 
+export const getData = cache(async () => {
+  const data = await client.getData();
+  return priceFeedsSchema.parse(
+    data.symbols.map((symbol) => ({
+      symbol,
+      product: data.productFromSymbol.get(symbol),
+      price: data.productPrice.get(symbol),
+    })),
+  );
+});
+
+const priceFeedsSchema = z.array(
+  z.object({
+    symbol: z.string(),
+    product: z.object({
+      display_symbol: z.string(),
+      asset_type: z.string(),
+      description: z.string(),
+      price_account: z.string(),
+      base: z.string().optional(),
+      country: z.string().optional(),
+      quote_currency: z.string().optional(),
+      tenor: z.string().optional(),
+      cms_symbol: z.string().optional(),
+      cqs_symbol: z.string().optional(),
+      nasdaq_symbol: z.string().optional(),
+      generic_symbol: z.string().optional(),
+      weekly_schedule: z.string().optional(),
+      schedule: z.string().optional(),
+      contract_id: z.string().optional(),
+    }),
+    price: z.object({
+      exponent: z.number(),
+      numComponentPrices: z.number(),
+      numQuoters: z.number(),
+      minPublishers: z.number(),
+      lastSlot: z.bigint(),
+      validSlot: z.bigint(),
+    }),
+  }),
+);
+
 export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => {
   const pythConn = new PythConnection(
     connection,

+ 98 - 0
apps/insights/src/use-query-param-filter-pagination.ts

@@ -0,0 +1,98 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { usePathname } from "next/navigation";
+import {
+  parseAsString,
+  parseAsInteger,
+  useQueryStates,
+  createSerializer,
+} from "nuqs";
+import { useCallback, useMemo } from "react";
+
+export const useQueryParamFilterPagination = <T>(
+  items: T[],
+  predicate: (item: T, term: string) => boolean,
+  options?: { defaultPageSize: number },
+) => {
+  const logger = useLogger();
+
+  const queryParams = useMemo(
+    () => ({
+      page: parseAsInteger.withDefault(1),
+      pageSize: parseAsInteger.withDefault(options?.defaultPageSize ?? 30),
+      search: parseAsString.withDefault(""),
+    }),
+    [options],
+  );
+
+  const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams);
+
+  const updateQuery = useCallback(
+    (...params: Parameters<typeof setQuery>) => {
+      setQuery(...params).catch((error: unknown) => {
+        logger.error("Failed to update query", error);
+      });
+    },
+    [setQuery, logger],
+  );
+
+  const updateSearch = useCallback(
+    (newSearch: string) => {
+      updateQuery({ page: 1, search: newSearch });
+    },
+    [updateQuery],
+  );
+
+  const updatePage = useCallback(
+    (newPage: number) => {
+      updateQuery({ page: newPage });
+    },
+    [updateQuery],
+  );
+
+  const updatePageSize = useCallback(
+    (newPageSize: number) => {
+      updateQuery({ page: 1, pageSize: newPageSize });
+    },
+    [updateQuery],
+  );
+
+  const filteredItems = useMemo(
+    () =>
+      search === "" ? items : items.filter((item) => predicate(item, search)),
+    [items, search, predicate],
+  );
+  const paginatedItems = useMemo(
+    () => filteredItems.slice((page - 1) * pageSize, page * pageSize),
+    [page, pageSize, filteredItems],
+  );
+
+  const numPages = useMemo(
+    () => Math.ceil(filteredItems.length / pageSize),
+    [filteredItems.length, pageSize],
+  );
+
+  const pathname = usePathname();
+
+  const mkPageLink = useCallback(
+    (page: number) => {
+      const serialize = createSerializer(queryParams);
+      return `${pathname}${serialize({ page, pageSize })}`;
+    },
+    [pathname, pageSize, queryParams],
+  );
+
+  return {
+    search,
+    page,
+    pageSize,
+    updateSearch,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numPages,
+    mkPageLink,
+    numResults: filteredItems.length,
+  };
+};

+ 0 - 18
apps/insights/src/zod-utils.ts

@@ -1,18 +0,0 @@
-import { type ZodSchema, type ZodTypeDef, z } from "zod";
-
-export const singletonArray = <Output, Def extends ZodTypeDef, Input>(
-  schema: ZodSchema<Output, Def, Input>,
-) =>
-  z
-    .array(schema)
-    .length(1)
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    .transform((value) => value[0]!);
-
-export const optionalSingletonArray = <Output, Def extends ZodTypeDef, Input>(
-  schema: ZodSchema<Output, Def, Input>,
-) =>
-  z
-    .array(schema)
-    .max(1)
-    .transform((value) => value[0]);

+ 40 - 45
packages/component-library/src/Alert/index.module.scss

@@ -5,59 +5,54 @@
   inset: 0;
   z-index: 1;
 
-  .modal {
+  .alert {
     position: fixed;
     bottom: theme.spacing(8);
     right: theme.spacing(8);
     outline: none;
+    background: theme.color("states", "info", "background-opaque");
+    border-radius: theme.border-radius("3xl");
+    backdrop-filter: blur(32px);
+    width: theme.spacing(156);
+    padding: theme.spacing(6);
+    padding-right: theme.spacing(16);
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(4);
+
+    .closeButton {
+      position: absolute;
+      right: theme.spacing(2);
+      top: theme.spacing(2);
+    }
 
-    .dialog {
-      background: theme.color("states", "info", "background-opaque");
-      border-radius: theme.border-radius("3xl");
-      backdrop-filter: blur(32px);
-      width: theme.spacing(156);
-      outline: none;
-      position: relative;
-      padding: theme.spacing(6);
-      padding-right: theme.spacing(16);
-      display: flex;
-      flex-flow: column nowrap;
-      gap: theme.spacing(4);
-
-      .closeButton {
-        position: absolute;
-        right: theme.spacing(2);
-        top: theme.spacing(2);
-      }
-
-      .title {
-        @include theme.h4;
-
-        display: flex;
-        flex-flow: row nowrap;
-        gap: theme.spacing(3);
-        align-items: center;
-        color: theme.color("heading");
-        line-height: 1;
+    .title {
+      @include theme.h4;
 
-        .icon {
-          color: theme.color("states", "info", "normal");
-          flex: none;
-          display: grid;
-          place-content: center;
-          font-size: theme.spacing(6);
-        }
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(3);
+      align-items: center;
+      color: theme.color("heading");
+      line-height: 1;
+
+      .icon {
+        color: theme.color("states", "info", "normal");
+        flex: none;
+        display: grid;
+        place-content: center;
+        font-size: theme.spacing(6);
       }
+    }
 
-      .body {
-        color: theme.color("paragraph");
-        font-size: theme.font-size("sm");
-        line-height: 140%;
-        display: flex;
-        flex-flow: column nowrap;
-        gap: theme.spacing(4);
-        align-items: flex-start;
-      }
+    .body {
+      color: theme.color("paragraph");
+      font-size: theme.font-size("sm");
+      line-height: 140%;
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(4);
+      align-items: flex-start;
     }
   }
 }

+ 27 - 23
packages/component-library/src/Alert/index.tsx

@@ -3,23 +3,27 @@
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
-import { Dialog, Heading } from "react-aria-components";
+import { Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
-import { Modal } from "../Modal/index.js";
+import { ModalDialog } from "../ModalDialog/index.js";
 
-export { DialogTrigger as AlertTrigger } from "react-aria-components";
+export { ModalDialogTrigger as AlertTrigger } 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 Modal>, "children"> & {
+type OwnProps = Pick<ComponentProps<typeof ModalDialog>, "children"> & {
   icon?: ReactNode | undefined;
   title: ReactNode;
 };
 
-type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
+type Props = Omit<
+  ComponentProps<typeof ModalDialog>,
+  keyof OwnProps | "overlayClassName"
+> &
+  OwnProps;
 
 export const Alert = ({
   icon,
@@ -28,23 +32,23 @@ export const Alert = ({
   className,
   ...props
 }: Props) => (
-  <Modal
-    overlayProps={{
-      className: styles.modalOverlay ?? "",
+  <ModalDialog
+    overlayClassName={styles.modalOverlay ?? ""}
+    variants={{
+      visible: {
+        y: 0,
+        transition: { type: "spring", duration: 0.75, bounce: 0.5 },
+      },
+      hidden: {
+        y: "100%",
+        transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+      },
     }}
-    initial={{ y: "100%" }}
-    animate={{
-      y: 0,
-      transition: { type: "spring", duration: 0.75, bounce: 0.5 },
-    }}
-    exit={{
-      y: "100%",
-      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-    }}
-    className={clsx(styles.modal, className)}
+    className={clsx(styles.alert, className)}
+    {...props}
   >
-    {(state) => (
-      <Dialog className={styles.dialog ?? ""} {...props}>
+    {(...args) => (
+      <>
         <Button
           className={styles.closeButton ?? ""}
           beforeIcon={(props) => <XCircle weight="fill" {...props} />}
@@ -61,9 +65,9 @@ export const Alert = ({
           <div>{title}</div>
         </Heading>
         <div className={styles.body}>
-          {typeof children === "function" ? children(state) : children}
+          {typeof children === "function" ? children(...args) : children}
         </div>
-      </Dialog>
+      </>
     )}
-  </Modal>
+  </ModalDialog>
 );

+ 3 - 3
packages/component-library/src/Breadcrumbs/index.tsx

@@ -6,7 +6,7 @@ import clsx from "clsx";
 import type { ComponentProps } from "react";
 
 import styles from "./index.module.scss";
-import { ButtonLink } from "../Button/index.js";
+import { Button } from "../Button/index.js";
 import { Link } from "../Link/index.js";
 import {
   UnstyledBreadcrumbs,
@@ -41,7 +41,7 @@ export const Breadcrumbs = ({ label, className, items, ...props }: Props) => (
           {"href" in item ? (
             <>
               {item.href === "/" ? (
-                <ButtonLink
+                <Button
                   size="xs"
                   variant="outline"
                   // I'm not quite sure why this is triggering, I'll need to
@@ -54,7 +54,7 @@ export const Breadcrumbs = ({ label, className, items, ...props }: Props) => (
                   href="/"
                 >
                   {item.label}
-                </ButtonLink>
+                </Button>
               ) : (
                 <Link href={item.href} className={styles.crumb ?? ""} invert>
                   {item.label}

+ 0 - 40
packages/component-library/src/Button/button-link.stories.tsx

@@ -1,40 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react";
-
-import { ButtonLink as ButtonLinkComponent } from "./index.js";
-import buttonMeta from "./index.stories.js";
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-const { onPress, isPending, ...argTypes } = buttonMeta.argTypes;
-const meta = {
-  component: ButtonLinkComponent,
-  title: "Button/ButtonLink",
-  argTypes: {
-    ...argTypes,
-    href: {
-      control: "text",
-      table: {
-        category: "Link",
-      },
-    },
-    target: {
-      control: "text",
-      table: {
-        category: "Link",
-      },
-    },
-  },
-} satisfies Meta<typeof ButtonLinkComponent>;
-export default meta;
-
-export const ButtonLink = {
-  args: {
-    children: "Link",
-    href: "https://www.pyth.network",
-    target: "_blank",
-    variant: "primary",
-    size: "md",
-    isDisabled: false,
-    rounded: false,
-    hideText: false,
-  },
-} satisfies StoryObj<typeof ButtonLinkComponent>;

+ 12 - 0
packages/component-library/src/Button/index.stories.tsx

@@ -71,6 +71,18 @@ const meta = {
         category: "State",
       },
     },
+    href: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
+    target: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
   },
 } satisfies Meta<typeof ButtonComponent>;
 export default meta;

+ 19 - 15
packages/component-library/src/Button/index.tsx

@@ -1,9 +1,10 @@
 import clsx from "clsx";
-import type { ComponentType, ReactNode } from "react";
-import {
-  type ButtonProps as BaseButtonProps,
-  type LinkProps as BaseLinkProps,
-} from "react-aria-components";
+import type {
+  ComponentProps,
+  ElementType,
+  ComponentType,
+  ReactNode,
+} from "react";
 
 import styles from "./index.module.scss";
 import { UnstyledButton } from "../UnstyledButton/index.js";
@@ -30,17 +31,20 @@ type OwnProps = {
   afterIcon?: Icon | undefined;
 };
 
-export type ButtonProps = Omit<BaseButtonProps, keyof OwnProps> & OwnProps;
+export type Props<T extends ElementType> = Omit<
+  ComponentProps<T>,
+  keyof OwnProps
+> &
+  OwnProps;
 
-export const Button = (props: ButtonProps) => (
-  <UnstyledButton {...buttonProps(props)} />
-);
-
-export type ButtonLinkProps = Omit<BaseLinkProps, keyof OwnProps> & OwnProps;
-
-export const ButtonLink = (props: ButtonLinkProps) => (
-  <UnstyledLink {...buttonProps(props)} />
-);
+export const Button = (
+  props: Props<typeof UnstyledButton> | Props<typeof UnstyledLink>,
+) =>
+  "href" in props ? (
+    <UnstyledLink {...buttonProps(props)} />
+  ) : (
+    <UnstyledButton {...buttonProps(props)} />
+  );
 
 type ButtonImplProps = OwnProps & {
   className?: Parameters<typeof clsx>[0];

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

@@ -33,19 +33,19 @@
   }
 
   .header {
+    display: flex;
     padding: theme.spacing(3) theme.spacing(4);
     position: relative;
 
     .title {
-      margin: 0;
-      font-size: theme.font-size("base");
-      font-weight: theme.font-weight("medium");
       color: theme.color("heading");
       display: inline-flex;
       flex-flow: row nowrap;
       gap: theme.spacing(3);
       align-items: center;
 
+      @include theme.text("lg", "medium");
+
       .icon {
         font-size: theme.spacing(6);
         height: theme.spacing(6);

+ 25 - 30
packages/component-library/src/Drawer/index.module.scss

@@ -6,49 +6,44 @@
   background: rgba(from black r g b / 30%);
   z-index: 1;
 
-  .modal {
+  .drawer {
     position: fixed;
     top: theme.spacing(4);
     bottom: theme.spacing(4);
     right: theme.spacing(4);
-    width: 40%;
+    width: 60%;
     max-width: theme.spacing(160);
     outline: none;
+    background: theme.color("background", "primary");
+    border: 1px solid theme.color("border");
+    border-radius: theme.border-radius("3xl");
+    display: flex;
+    flex-flow: column nowrap;
+    overflow-y: hidden;
+    padding-bottom: theme.border-radius("3xl");
 
-    .dialog {
-      background: theme.color("background", "primary");
-      border: 1px solid theme.color("border");
-      border-radius: theme.border-radius("3xl");
-      outline: none;
+    .heading {
+      padding: theme.spacing(4);
+      padding-left: theme.spacing(6);
       display: flex;
-      flex-flow: column nowrap;
-      height: 100%;
-      overflow-y: hidden;
-      padding-bottom: theme.border-radius("3xl");
+      flex-flow: row nowrap;
+      justify-content: space-between;
+      align-items: center;
+      color: theme.color("heading");
+      flex: none;
+
+      .title {
+        @include theme.h4;
 
-      .heading {
-        padding: theme.spacing(4);
-        padding-left: theme.spacing(6);
         display: flex;
         flex-flow: row nowrap;
-        justify-content: space-between;
-        align-items: center;
-        color: theme.color("heading");
-        flex: none;
-
-        .title {
-          @include theme.h4;
-
-          display: flex;
-          flex-flow: row nowrap;
-          gap: theme.spacing(3);
-        }
+        gap: theme.spacing(3);
       }
+    }
 
-      .body {
-        flex: 1;
-        overflow-y: auto;
-      }
+    .body {
+      flex: 1;
+      overflow-y: auto;
     }
   }
 }

+ 39 - 27
packages/component-library/src/Drawer/index.tsx

@@ -3,45 +3,56 @@
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
-import { Dialog, Heading } from "react-aria-components";
+import { Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
-import { Modal } from "../Modal/index.js";
+import { ModalDialog } from "../ModalDialog/index.js";
 
-export { DialogTrigger as DrawerTrigger } from "react-aria-components";
+export { ModalDialogTrigger as DrawerTrigger } from "../ModalDialog/index.js";
 
 const CLOSE_DURATION_IN_S = 0.15;
 export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 
-type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> & {
+type OwnProps = {
   title: ReactNode;
+  closeHref?: string | undefined;
 };
 
-type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
+type Props = Omit<
+  ComponentProps<typeof ModalDialog>,
+  keyof OwnProps | "overlayVariants" | "overlayClassName" | "variants"
+> &
+  OwnProps;
 
-export const Drawer = ({ title, children, className, ...props }: Props) => (
-  <Modal
-    overlayProps={{
-      initial: { backgroundColor: "#00000000" },
-      animate: { backgroundColor: "#00000080" },
-      exit: { backgroundColor: "#00000000" },
-      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-      className: styles.modalOverlay ?? "",
+export const Drawer = ({
+  className,
+  title,
+  closeHref,
+  children,
+  ...props
+}: Props) => (
+  <ModalDialog
+    overlayVariants={{
+      hidden: { backgroundColor: "#00000000" },
+      visible: { backgroundColor: "#00000080" },
     }}
-    initial={{ x: "100%" }}
-    animate={{
-      x: 0,
-      transition: { type: "spring", duration: 1, bounce: 0.35 },
+    overlayClassName={styles.modalOverlay ?? ""}
+    variants={{
+      visible: {
+        x: 0,
+        transition: { type: "spring", duration: 1, bounce: 0.35 },
+      },
+      hidden: {
+        x: "calc(100% + 1rem)",
+        transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+      },
     }}
-    exit={{
-      x: "100%",
-      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-    }}
-    className={clsx(styles.modal, className)}
+    className={clsx(styles.drawer, className)}
+    {...props}
   >
-    {(state) => (
-      <Dialog className={styles.dialog ?? ""} {...props}>
+    {(...args) => (
+      <>
         <div className={styles.heading}>
           <Heading className={styles.title} slot="title">
             {title}
@@ -54,14 +65,15 @@ export const Drawer = ({ title, children, className, ...props }: Props) => (
             rounded
             variant="ghost"
             size="sm"
+            {...(closeHref && { href: closeHref })}
           >
             Close
           </Button>
         </div>
         <div className={styles.body}>
-          {typeof children === "function" ? children(state) : children}
+          {typeof children === "function" ? children(...args) : children}
         </div>
-      </Dialog>
+      </>
     )}
-  </Modal>
+  </ModalDialog>
 );

+ 14 - 0
packages/component-library/src/DropdownCaretDown/index.tsx

@@ -0,0 +1,14 @@
+import type { ComponentProps } from "react";
+
+export const DropdownCaretDown = (
+  props: Omit<ComponentProps<"svg">, "xmlns" | "viewBox" | "fill">,
+) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 20 20"
+    fill="currentColor"
+    {...props}
+  >
+    <path d="m13.346 9.284-3.125 3.125a.311.311 0 0 1-.442 0L6.654 9.284a.312.312 0 0 1 .221-.534h6.25a.312.312 0 0 1 .221.534Z" />
+  </svg>
+);

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

@@ -29,6 +29,7 @@ html {
   &[data-overlay-visible] {
     scrollbar-gutter: auto;
     padding-right: var(--scrollbar-width) !important;
+    overflow: hidden;
   }
 }
 

+ 0 - 1
packages/component-library/src/MainNavTabs/index.tsx

@@ -36,7 +36,6 @@ export const MainNavTabs = ({ className, pathname, ...props }: Props) => (
             {args.isSelected && (
               <motion.span
                 layoutId="bubble"
-                // @ts-expect-error Looks like framer-motion has a bug in it's typings...
                 className={styles.bubble}
                 transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
                 style={{ originY: "top" }}

+ 0 - 78
packages/component-library/src/Modal/index.tsx

@@ -1,78 +0,0 @@
-"use client";
-
-import { motion, AnimatePresence } from "motion/react";
-import {
-  type ComponentProps,
-  type ContextType,
-  type ReactNode,
-  use,
-  useCallback,
-  useEffect,
-} from "react";
-import {
-  Modal as ModalComponent,
-  ModalOverlay,
-  OverlayTriggerStateContext,
-} from "react-aria-components";
-
-import { useSetOverlayVisible } from "../overlay-visible-context.js";
-
-// @ts-expect-error Looks like there's a typing mismatch currently between
-// motion and react, probably due to us being on react 19.  I'm expecting this
-// will go away when react 19 is officially stabilized...
-const MotionModal = motion.create(ModalComponent);
-
-// @ts-expect-error Looks like there's a typing mismatch currently between
-// motion and react, probably due to us being on react 19.  I'm expecting this
-// will go away when react 19 is officially stabilized...
-const MotionModalOverlay = motion.create(ModalOverlay);
-
-type OwnProps = {
-  overlayProps?: Omit<
-    ComponentProps<typeof MotionModalOverlay>,
-    "isOpen" | "isDismissable" | "onOpenChange"
-  >;
-  children:
-    | ReactNode
-    | ((
-        state: NonNullable<ContextType<typeof OverlayTriggerStateContext>>,
-      ) => ReactNode);
-};
-
-type Props = Omit<ComponentProps<typeof MotionModal>, keyof OwnProps> &
-  OwnProps;
-
-export const Modal = ({ overlayProps, children, ...props }: Props) => {
-  const state = use(OverlayTriggerStateContext);
-  const { hideOverlay, showOverlay } = useSetOverlayVisible();
-
-  useEffect(() => {
-    if (state?.isOpen) {
-      showOverlay();
-    }
-  }, [state, showOverlay]);
-
-  const onOpenChange = useCallback(
-    (newValue: boolean) => {
-      state?.setOpen(newValue);
-    },
-    [state],
-  );
-
-  return (
-    <AnimatePresence onExitComplete={hideOverlay}>
-      {state?.isOpen && (
-        <MotionModalOverlay
-          isOpen
-          isDismissable
-          onOpenChange={onOpenChange}
-          {...overlayProps}
-        >
-          <MotionModal {...props}>
-            {typeof children === "function" ? children(state) : children}
-          </MotionModal>
-        </MotionModalOverlay>
-      )}
-    </AnimatePresence>
-  );
-};

+ 127 - 0
packages/component-library/src/ModalDialog/index.tsx

@@ -0,0 +1,127 @@
+"use client";
+
+import { motion } from "motion/react";
+import {
+  type ComponentProps,
+  type Dispatch,
+  type SetStateAction,
+  createContext,
+  use,
+  useCallback,
+  useState,
+  useEffect,
+} from "react";
+import {
+  Modal,
+  ModalOverlay,
+  Dialog,
+  DialogTrigger,
+} from "react-aria-components";
+
+import { useSetOverlayVisible } from "../overlay-visible-context.js";
+
+const MotionModalOverlay = motion.create(ModalOverlay);
+const MotionDialog = motion.create(Dialog);
+
+export const ModalDialogTrigger = (
+  props: ComponentProps<typeof DialogTrigger>,
+) => {
+  const [animation, setAnimation] = useState<AnimationState>("unmounted");
+
+  const handleOpenChange = useCallback(
+    (isOpen: boolean) => {
+      setAnimation(isOpen ? "visible" : "hidden");
+    },
+    [setAnimation],
+  );
+
+  return (
+    <ModalAnimationContext value={[animation, setAnimation]}>
+      <DialogTrigger onOpenChange={handleOpenChange} {...props} />
+    </ModalAnimationContext>
+  );
+};
+
+const ModalAnimationContext = createContext<
+  [AnimationState, Dispatch<SetStateAction<AnimationState>>] | undefined
+>(undefined);
+
+type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> &
+  Pick<ComponentProps<typeof MotionModalOverlay>, "isOpen" | "onOpenChange"> & {
+    overlayClassName?:
+      | ComponentProps<typeof MotionModalOverlay>["className"]
+      | undefined;
+    overlayVariants?:
+      | ComponentProps<typeof MotionModalOverlay>["variants"]
+      | undefined;
+  };
+
+type Props = Omit<ComponentProps<typeof MotionDialog>, keyof OwnProps> &
+  OwnProps;
+
+export const ModalDialog = ({
+  isOpen,
+  onOpenChange,
+  overlayClassName,
+  overlayVariants,
+  children,
+  ...props
+}: Props) => {
+  const contextAnimationState = use(ModalAnimationContext);
+  const localAnimationState = useState<AnimationState>("unmounted");
+  const [animation, setAnimation] =
+    contextAnimationState ?? localAnimationState;
+  const { hideOverlay, showOverlay } = useSetOverlayVisible();
+
+  const startAnimation = (animation: AnimationState) => {
+    if (animation === "visible") {
+      showOverlay();
+    }
+  };
+
+  const endAnimation = (animation: AnimationState) => {
+    if (animation === "hidden") {
+      hideOverlay();
+    }
+    setAnimation((a) => {
+      return animation === "hidden" && a === "hidden" ? "unmounted" : a;
+    });
+  };
+
+  useEffect(() => {
+    if (isOpen !== undefined) {
+      setAnimation((a) => {
+        if (isOpen) {
+          return "visible";
+        } else {
+          return a === "visible" ? "hidden" : a;
+        }
+      });
+    }
+  }, [isOpen, setAnimation]);
+
+  return (
+    <MotionModalOverlay
+      isDismissable
+      isExiting={animation === "hidden"}
+      onAnimationStart={startAnimation}
+      onAnimationComplete={endAnimation}
+      initial="hidden"
+      animate={animation}
+      {...(onOpenChange && { onOpenChange })}
+      {...(overlayVariants && { variants: overlayVariants })}
+      {...(overlayClassName && { className: overlayClassName })}
+      {...(isOpen !== undefined && { isOpen })}
+    >
+      <Modal>
+        {(...args) => (
+          <MotionDialog {...props}>
+            {typeof children === "function" ? children(...args) : children}
+          </MotionDialog>
+        )}
+      </Modal>
+    </MotionModalOverlay>
+  );
+};
+
+type AnimationState = "unmounted" | "hidden" | "visible";

+ 6 - 5
packages/component-library/src/Paginator/index.tsx

@@ -2,9 +2,10 @@ import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
 import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
 import clsx from "clsx";
 import { type ComponentProps, useMemo, useCallback } from "react";
+import type { Link } from "react-aria-components";
 
 import styles from "./index.module.scss";
-import { Button, ButtonLink } from "../Button/index.js";
+import { type Props as ButtonProps, Button } from "../Button/index.js";
 import buttonStyles from "../Button/index.module.scss";
 import { Select } from "../Select/index.js";
 import { UnstyledToolbar } from "../UnstyledToolbar/index.js";
@@ -153,7 +154,7 @@ const PaginatorToolbar = ({
 };
 
 type PageSelectorProps = Pick<
-  ComponentProps<typeof ButtonLink>,
+  ComponentProps<typeof Button>,
   "hideText" | "beforeIcon" | "isDisabled" | "children"
 > & {
   page: number;
@@ -169,7 +170,7 @@ const PageSelector = ({ mkPageLink, ...props }: PageSelectorProps) =>
   );
 
 type PageLinkProps = Omit<
-  ComponentProps<typeof ButtonLink>,
+  ButtonProps<typeof Link>,
   "variant" | "size" | "href" | "onPress"
 > & {
   page: number;
@@ -190,7 +191,7 @@ const PageLink = ({
   }, [onPageChange, page]);
 
   return (
-    <ButtonLink
+    <Button
       variant="ghost"
       size="sm"
       onPress={onPress}
@@ -202,7 +203,7 @@ const PageLink = ({
 };
 
 type PageButtonProps = Omit<
-  ComponentProps<typeof Button>,
+  ButtonProps<typeof Link>,
   "variant" | "size" | "href" | "onPress"
 > & {
   page: number;

+ 1 - 3
packages/component-library/src/Select/index.module.scss

@@ -2,9 +2,7 @@
 
 .select {
   .caret {
-    transition-property: transform;
-    transition-duration: 300ms;
-    transition-timing-function: ease;
+    transition: transform 300ms ease;
   }
 
   &[data-open] {

+ 4 - 15
packages/component-library/src/Select/index.tsx

@@ -3,6 +3,7 @@ import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
 import {
   type PopoverProps,
+  type Button as BaseButton,
   Label,
   Select as BaseSelect,
   Popover,
@@ -15,14 +16,15 @@ import {
 } from "react-aria-components";
 
 import styles from "./index.module.scss";
-import { Button } from "../Button/index.js";
+import { type Props as ButtonProps, Button } from "../Button/index.js";
+import { DropdownCaretDown } from "../DropdownCaretDown/index.js";
 
 type Props<T> = Omit<
   ComponentProps<typeof BaseSelect>,
   "defaultSelectedKey" | "selectedKey" | "onSelectionChange"
 > &
   Pick<
-    ComponentProps<typeof Button>,
+    ButtonProps<typeof BaseButton>,
     "variant" | "size" | "rounded" | "hideText" | "isPending"
   > &
   Pick<PopoverProps, "placement"> & {
@@ -142,16 +144,3 @@ const Item = <T extends string | number>({ children, show }: ItemProps<T>) => (
     <Check weight="bold" className={styles.check} />
   </ListBoxItem>
 );
-
-const DropdownCaretDown = (
-  props: Omit<ComponentProps<"svg">, "xmlns" | "viewBox" | "fill">,
-) => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 20 20"
-    fill="currentColor"
-    {...props}
-  >
-    <path d="m13.346 9.284-3.125 3.125a.311.311 0 0 1-.442 0L6.654 9.284a.312.312 0 0 1 .221-.534h6.25a.312.312 0 0 1 .221.534Z" />
-  </svg>
-);

+ 4 - 33
packages/component-library/src/Table/index.module.scss

@@ -39,8 +39,7 @@
     border-spacing: 0;
 
     .cell {
-      padding-left: theme.spacing(3);
-      padding-right: theme.spacing(3);
+      padding: theme.spacing(3) theme.spacing(4);
       white-space: nowrap;
       border: 0;
       outline: theme.spacing(0.5) solid transparent;
@@ -49,14 +48,6 @@
       background-color: theme.color("background", "primary");
       transition: outline-color 100ms linear;
 
-      &:first-child {
-        padding-left: theme.spacing(4);
-      }
-
-      &:last-child {
-        padding-right: theme.spacing(4);
-      }
-
       &[data-alignment="left"] {
         text-align: left;
       }
@@ -91,10 +82,8 @@
       color: theme.color("muted");
 
       .cell {
-        border-bottom: 1px solid theme.color("background", "secondary");
+        border-bottom: 1px solid theme.color("border");
         font-weight: theme.font-weight("medium");
-        padding-top: theme.spacing(3);
-        padding-bottom: theme.spacing(3);
         position: sticky;
         top: 0;
         z-index: 1;
@@ -106,7 +95,7 @@
     }
 
     .tableBody {
-      @include theme.text("sm", "medium");
+      @include theme.text("base", "medium");
 
       color: theme.color("paragraph");
       font-weight: theme.font-weight("medium");
@@ -125,9 +114,8 @@
         }
 
         .cell {
-          padding-top: theme.spacing(4);
-          padding-bottom: theme.spacing(4);
           transition: background-color 100ms linear;
+          border-bottom: 1px solid theme.color("background", "secondary");
         }
 
         &[data-hovered] .cell {
@@ -155,23 +143,6 @@
     width: 100%;
   }
 
-  &[data-divide] {
-    .table {
-      // This rule has lower specificity than a rule above which applies the
-      // background color to hovered / pressed body cells, but csslint has no
-      // way to understand that .tableHeader and .tableBody are mutually
-      // exclusive and so these rules will never override other other.
-      // stylelint-disable-next-line no-descending-specificity
-      .tableHeader .cell {
-        border-color: theme.color("border");
-      }
-
-      .tableBody .row .cell {
-        border-bottom: 1px solid theme.color("background", "secondary");
-      }
-    }
-  }
-
   &[data-rounded] {
     border-radius: theme.border-radius("xl");
 

+ 0 - 7
packages/component-library/src/Table/index.stories.tsx

@@ -42,12 +42,6 @@ const meta = {
         category: "State",
       },
     },
-    divide: {
-      control: "boolean",
-      table: {
-        category: "Variant",
-      },
-    },
     fill: {
       control: "boolean",
       table: {
@@ -75,7 +69,6 @@ export const Table = {
     isUpdating: false,
     isLoading: false,
     fill: true,
-    divide: false,
     rounded: true,
     columns: [
       {

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

@@ -22,7 +22,6 @@ import {
 type TableProps<T extends string> = {
   className?: string | undefined;
   fill?: boolean | undefined;
-  divide?: boolean | undefined;
   rounded?: boolean | undefined;
   label: string;
   columns: ColumnConfig<T>[];
@@ -60,7 +59,6 @@ export type RowConfig<T extends string> = Omit<
 export const Table = <T extends string>({
   className,
   fill,
-  divide,
   rounded,
   label,
   rows,
@@ -73,7 +71,6 @@ export const Table = <T extends string>({
   <div
     className={clsx(styles.tableContainer, className)}
     data-fill={fill ? "" : undefined}
-    data-divide={divide ? "" : undefined}
     data-rounded={rounded ? "" : undefined}
   >
     {isUpdating && (

+ 0 - 1
packages/component-library/src/Tabs/index.tsx

@@ -40,7 +40,6 @@ export const Tabs = ({ label, className, pathname, ...props }: Props) => (
               {args.isSelected && (
                 <motion.span
                   layoutId="underline"
-                  // @ts-expect-error Looks like framer-motion has a bug in it's typings...
                   className={styles.underline}
                   transition={{ type: "spring", bounce: 0.6, duration: 0.6 }}
                   style={{ originY: "top" }}

+ 61 - 18
pnpm-lock.yaml

@@ -112,8 +112,8 @@ catalogs:
       specifier: 3.0.1
       version: 3.0.1
     motion:
-      specifier: 11.11.17
-      version: 11.11.17
+      specifier: 11.14.4
+      version: 11.14.4
     next:
       specifier: 15.1.0
       version: 15.1.0
@@ -168,6 +168,9 @@ catalogs:
     stylelint-config-standard-scss:
       specifier: 13.1.0
       version: 13.1.0
+    superjson:
+      specifier: 2.2.2
+      version: 2.2.2
     swr:
       specifier: 2.2.5
       version: 2.2.5
@@ -443,7 +446,7 @@ importers:
         version: 2.14.0
       motion:
         specifier: 'catalog:'
-        version: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       next:
         specifier: 'catalog:'
         version: 15.1.0(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7)
@@ -468,6 +471,9 @@ importers:
       recharts:
         specifier: 'catalog:'
         version: 2.14.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      superjson:
+        specifier: 'catalog:'
+        version: 2.2.2
       swr:
         specifier: 'catalog:'
         version: 2.2.5(react@19.0.0)
@@ -1616,7 +1622,7 @@ importers:
         version: 3.0.1
       motion:
         specifier: 'catalog:'
-        version: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+        version: 11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       react-aria:
         specifier: 'catalog:'
         version: 3.36.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
@@ -12930,6 +12936,10 @@ packages:
     resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
     engines: {node: '>= 0.6'}
 
+  copy-anything@3.0.5:
+    resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
+    engines: {node: '>=12.13'}
+
   copy-to-clipboard@3.3.3:
     resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
 
@@ -15053,12 +15063,12 @@ packages:
       react-dom:
         optional: true
 
-  framer-motion@11.11.17:
-    resolution: {integrity: sha512-O8QzvoKiuzI5HSAHbcYuL6xU+ZLXbrH7C8Akaato4JzQbX2ULNeniqC2Vo5eiCtFktX9XsJ+7nUhxcl2E2IjpA==}
+  framer-motion@11.15.0:
+    resolution: {integrity: sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==}
     peerDependencies:
       '@emotion/is-prop-valid': '*'
-      react: ^18.0.0
-      react-dom: ^18.0.0
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
     peerDependenciesMeta:
       '@emotion/is-prop-valid':
         optional: true
@@ -16286,6 +16296,10 @@ packages:
     resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==}
     engines: {node: '>= 0.4'}
 
+  is-what@4.1.16:
+    resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+    engines: {node: '>=12.13'}
+
   is-windows@1.0.2:
     resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
     engines: {node: '>=0.10.0'}
@@ -17928,15 +17942,21 @@ packages:
   moment@2.29.4:
     resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
 
+  motion-dom@11.14.3:
+    resolution: {integrity: sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==}
+
+  motion-utils@11.14.3:
+    resolution: {integrity: sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==}
+
   motion@10.16.2:
     resolution: {integrity: sha512-p+PurYqfUdcJZvtnmAqu5fJgV2kR0uLFQuBKtLeFVTrYEVllI99tiOTSefVNYuip9ELTEkepIIDftNdze76NAQ==}
 
-  motion@11.11.17:
-    resolution: {integrity: sha512-y6mXYElvJ5HHwPBUpYG/5wclKVGW4hJhqPkTjWccib5/WrcRM185adg3+4aSmG5iD10XKFt5uBOAiKwuzMHPPQ==}
+  motion@11.14.4:
+    resolution: {integrity: sha512-ZIaw6ko88B8rSmBEFzqbTCQMbo9xMu8f4PSXSGdb9DTDy8R0sXcbwMEKmTEYkrj9TmZ4n+Ebd0KYjtqHgzRkRQ==}
     peerDependencies:
       '@emotion/is-prop-valid': '*'
-      react: ^18.0.0
-      react-dom: ^18.0.0
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
     peerDependenciesMeta:
       '@emotion/is-prop-valid':
         optional: true
@@ -21128,6 +21148,11 @@ packages:
 
   sudo-prompt@9.2.1:
     resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==}
+    deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+
+  superjson@2.2.2:
+    resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
+    engines: {node: '>=16'}
 
   superstruct@0.14.2:
     resolution: {integrity: sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ==}
@@ -44429,6 +44454,10 @@ snapshots:
 
   cookie@0.6.0: {}
 
+  copy-anything@3.0.5:
+    dependencies:
+      is-what: 4.1.16
+
   copy-to-clipboard@3.3.3:
     dependencies:
       toggle-selection: 1.0.6
@@ -47970,16 +47999,20 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
-  framer-motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
+  framer-motion@11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
     dependencies:
+      motion-dom: 11.14.3
+      motion-utils: 11.14.3
       tslib: 2.8.0
     optionalDependencies:
       '@emotion/is-prop-valid': 1.2.2
       react: 19.0.0
       react-dom: 18.3.1(react@18.3.1)
 
-  framer-motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+  framer-motion@11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
     dependencies:
+      motion-dom: 11.14.3
+      motion-utils: 11.14.3
       tslib: 2.8.0
     optionalDependencies:
       '@emotion/is-prop-valid': 1.2.2
@@ -49515,6 +49548,8 @@ snapshots:
       call-bind: 1.0.7
       get-intrinsic: 1.2.4
 
+  is-what@4.1.16: {}
+
   is-windows@1.0.2: {}
 
   is-wsl@1.1.0: {}
@@ -53558,6 +53593,10 @@ snapshots:
 
   moment@2.29.4: {}
 
+  motion-dom@11.14.3: {}
+
+  motion-utils@11.14.3: {}
+
   motion@10.16.2:
     dependencies:
       '@motionone/animation': 10.18.0
@@ -53567,18 +53606,18 @@ snapshots:
       '@motionone/utils': 10.18.0
       '@motionone/vue': 10.16.4
 
-  motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
+  motion@11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
     dependencies:
-      framer-motion: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      framer-motion: 11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       tslib: 2.8.0
     optionalDependencies:
       '@emotion/is-prop-valid': 1.2.2
       react: 19.0.0
       react-dom: 18.3.1(react@18.3.1)
 
-  motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+  motion@11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
     dependencies:
-      framer-motion: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      framer-motion: 11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       tslib: 2.8.0
     optionalDependencies:
       '@emotion/is-prop-valid': 1.2.2
@@ -57962,6 +58001,10 @@ snapshots:
 
   sudo-prompt@9.2.1: {}
 
+  superjson@2.2.2:
+    dependencies:
+      copy-anything: 3.0.5
+
   superstruct@0.14.2: {}
 
   superstruct@0.15.5: {}

+ 2 - 1
pnpm-workspace.yaml

@@ -69,7 +69,7 @@ catalog:
   framer-motion: 11.11.10
   jest: 29.7.0
   modern-normalize: 3.0.1
-  motion: 11.11.17
+  motion: 11.14.4
   next-themes: 0.3.0
   next: 15.1.0
   nuqs: 2.1.2
@@ -88,6 +88,7 @@ catalog:
   style-loader: 4.0.0
   stylelint-config-standard-scss: 13.1.0
   stylelint: 16.10.0
+  superjson: 2.2.2
   swr: 2.2.5
   tailwindcss-animate: 1.0.7
   tailwindcss-react-aria-components: 1.1.6