Explorar o código

feat(insights): add publisher details page

Connor Prussin hai 10 meses
pai
achega
4720ef36c2
Modificáronse 80 ficheiros con 3224 adicións e 601 borrados
  1. 0 11
      apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx
  2. 0 1
      apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx
  3. 0 3
      apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx
  4. 1 0
      apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx
  5. 1 1
      apps/insights/src/app/price-feeds/layout.ts
  6. 3 0
      apps/insights/src/app/publishers/[key]/error.ts
  7. 13 0
      apps/insights/src/app/publishers/[key]/layout.ts
  8. 1 0
      apps/insights/src/app/publishers/[key]/page.ts
  9. 1 0
      apps/insights/src/app/publishers/[key]/price-feeds/page.tsx
  10. 1 0
      apps/insights/src/app/publishers/layout.ts
  11. 2 16
      apps/insights/src/app/yesterdays-prices/route.ts
  12. 7 18
      apps/insights/src/components/ChangePercent/index.tsx
  13. 1 1
      apps/insights/src/components/ChangeValue/index.module.scss
  14. 46 0
      apps/insights/src/components/ChangeValue/index.tsx
  15. 5 9
      apps/insights/src/components/CopyButton/index.tsx
  16. 13 0
      apps/insights/src/components/FormattedDate/index.tsx
  17. 34 0
      apps/insights/src/components/Meter/index.module.scss
  18. 35 0
      apps/insights/src/components/Meter/index.tsx
  19. 25 57
      apps/insights/src/components/PriceComponentsCard/index.tsx
  20. 5 5
      apps/insights/src/components/PriceFeed/layout.tsx
  21. 0 44
      apps/insights/src/components/PriceFeed/price-component-drawer.tsx
  22. 0 58
      apps/insights/src/components/PriceFeed/price-components.tsx
  23. 44 0
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  24. 0 0
      apps/insights/src/components/PriceFeed/publishers.module.scss
  25. 53 0
      apps/insights/src/components/PriceFeed/publishers.tsx
  26. 1 0
      apps/insights/src/components/PriceFeed/reference-data.tsx
  27. 2 0
      apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx
  28. 1 0
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  29. 2 1
      apps/insights/src/components/PriceFeeds/index.tsx
  30. 2 0
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  31. 53 0
      apps/insights/src/components/Publisher/active-feeds-card.tsx
  32. 110 0
      apps/insights/src/components/Publisher/chart-card.tsx
  33. 24 0
      apps/insights/src/components/Publisher/get-rankings-with-data.ts
  34. 136 0
      apps/insights/src/components/Publisher/layout.module.scss
  35. 403 0
      apps/insights/src/components/Publisher/layout.tsx
  36. 177 0
      apps/insights/src/components/Publisher/median-score-history.module.scss
  37. 301 0
      apps/insights/src/components/Publisher/median-score-history.tsx
  38. 56 0
      apps/insights/src/components/Publisher/ois-apy-history.module.scss
  39. 88 0
      apps/insights/src/components/Publisher/ois-apy-history.tsx
  40. 12 0
      apps/insights/src/components/Publisher/performance.module.scss
  41. 188 0
      apps/insights/src/components/Publisher/performance.tsx
  42. 32 0
      apps/insights/src/components/Publisher/price-feeds.tsx
  43. 9 0
      apps/insights/src/components/PublisherKey/index.module.scss
  44. 27 0
      apps/insights/src/components/PublisherKey/index.tsx
  45. 0 5
      apps/insights/src/components/PublisherTag/index.module.scss
  46. 6 16
      apps/insights/src/components/PublisherTag/index.tsx
  47. 15 46
      apps/insights/src/components/Publishers/index.module.scss
  48. 32 54
      apps/insights/src/components/Publishers/index.tsx
  49. 3 1
      apps/insights/src/components/Publishers/publishers-card.tsx
  50. 0 95
      apps/insights/src/components/Publishers/semicircle-meter.tsx
  51. 11 11
      apps/insights/src/components/Ranking/index.module.scss
  52. 7 1
      apps/insights/src/components/Ranking/index.tsx
  53. 5 0
      apps/insights/src/components/Root/index.module.scss
  54. 34 0
      apps/insights/src/components/SemicircleMeter/index.module.scss
  55. 73 0
      apps/insights/src/components/SemicircleMeter/index.tsx
  56. 11 10
      apps/insights/src/components/Tabs/index.tsx
  57. 2 1
      apps/insights/src/components/TokenIcon/index.module.scss
  58. 1 1
      apps/insights/src/components/ZoomLayoutTransition/index.tsx
  59. 3 0
      apps/insights/src/config/server.ts
  60. 185 23
      apps/insights/src/services/clickhouse.ts
  61. 14 1
      apps/insights/src/services/hermes.ts
  62. 25 11
      apps/insights/src/services/pyth.ts
  63. 71 3
      apps/insights/src/services/staking.ts
  64. 6 0
      apps/insights/stylelint.config.js
  65. 2 1
      apps/insights/turbo.json
  66. 0 1
      packages/component-library/package.json
  67. 2 1
      packages/component-library/src/Alert/index.tsx
  68. 3 6
      packages/component-library/src/Breadcrumbs/index.tsx
  69. 10 2
      packages/component-library/src/Card/index.tsx
  70. 21 0
      packages/component-library/src/Drawer/index.module.scss
  71. 21 2
      packages/component-library/src/Drawer/index.tsx
  72. 38 0
      packages/component-library/src/InfoBox/index.module.scss
  73. 19 0
      packages/component-library/src/InfoBox/index.tsx
  74. 1 1
      packages/component-library/src/ModalDialog/index.tsx
  75. 36 8
      packages/component-library/src/StatCard/index.module.scss
  76. 82 19
      packages/component-library/src/StatCard/index.tsx
  77. 14 6
      packages/component-library/src/Table/index.module.scss
  78. 32 9
      packages/component-library/src/Table/index.tsx
  79. 14 0
      packages/component-library/src/theme.scss
  80. 510 41
      pnpm-lock.yaml

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

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

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

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

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

@@ -1,3 +0,0 @@
-// eslint-disable-next-line unicorn/no-null
-const Page = () => null;
-export default Page;

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

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

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

@@ -1 +1 @@
-export { PriceFeedsLayout as default } from "../../components/PriceFeeds/layout";
+export { ZoomLayoutTransition as default } from "../../components/ZoomLayoutTransition";

+ 3 - 0
apps/insights/src/app/publishers/[key]/error.ts

@@ -0,0 +1,3 @@
+"use client";
+
+export { Error as default } from "../../../components/Error";

+ 13 - 0
apps/insights/src/app/publishers/[key]/layout.ts

@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+
+export { PublishersLayout as default } from "../../../components/Publisher/layout";
+import { getPublishers } from "../../../services/clickhouse";
+
+export const metadata: Metadata = {
+  title: "Publishers",
+};
+
+export const generateStaticParams = async () => {
+  const publishers = await getPublishers();
+  return publishers.map(({ key }) => ({ key }));
+};

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

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

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

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

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

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

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

@@ -1,25 +1,11 @@
 import type { NextRequest } from "next/server";
-import { z } from "zod";
 
-import { client } from "../../services/clickhouse";
+import { getYesterdaysPrices } from "../../services/clickhouse";
 
 export async function GET(req: NextRequest) {
   const symbols = req.nextUrl.searchParams.getAll("symbols");
-  const rows = await client.query({
-    query:
-      "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})",
-    query_params: { symbols },
-  });
-  const result = await rows.json();
-  const data = schema.parse(result.data);
+  const data = await getYesterdaysPrices(symbols);
   return Response.json(
     Object.fromEntries(data.map(({ symbol, price }) => [symbol, price])),
   );
 }
-
-const schema = z.array(
-  z.object({
-    symbol: z.string(),
-    price: z.number(),
-  }),
-);

+ 7 - 18
apps/insights/src/components/ChangePercent/index.tsx

@@ -1,14 +1,11 @@
 "use client";
 
-import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import clsx from "clsx";
 import { type ComponentProps, createContext, use } from "react";
 import { useNumberFormatter } from "react-aria";
 import { z } from "zod";
 
-import styles from "./index.module.scss";
 import { StateType, useData } from "../../use-data";
+import { ChangeValue } from "../ChangeValue";
 import { useLivePrice } from "../LivePrices";
 
 const ONE_SECOND_IN_MS = 1000;
@@ -16,8 +13,6 @@ const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
 const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS;
 const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
 
-const CHANGE_PERCENT_SKELETON_WIDTH = 15;
-
 type Props = Omit<ComponentProps<typeof YesterdaysPricesContext>, "value"> & {
   feeds: (Feed & { symbol: string })[];
 };
@@ -92,12 +87,7 @@ export const ChangePercent = ({ feed, className }: ChangePercentProps) => {
 
     case StateType.Loading:
     case StateType.NotLoaded: {
-      return (
-        <Skeleton
-          className={clsx(styles.changePercent, className)}
-          width={CHANGE_PERCENT_SKELETON_WIDTH}
-        />
-      );
+      return <ChangeValue className={className} isLoading />;
     }
 
     case StateType.Loaded: {
@@ -107,7 +97,7 @@ export const ChangePercent = ({ feed, className }: ChangePercentProps) => {
       // eslint-disable-next-line unicorn/no-null
       return yesterdaysPrice === undefined ? null : (
         <ChangePercentLoaded
-          className={clsx(styles.changePercent, className)}
+          className={className}
           priorPrice={yesterdaysPrice}
           feed={feed}
         />
@@ -130,7 +120,7 @@ const ChangePercentLoaded = ({
   const currentPrice = useLivePrice(feed);
 
   return currentPrice === undefined ? (
-    <Skeleton className={className} width={CHANGE_PERCENT_SKELETON_WIDTH} />
+    <ChangeValue className={className} isLoading />
   ) : (
     <PriceDifference
       className={className}
@@ -155,13 +145,12 @@ const PriceDifference = ({
   const direction = getDirection(currentPrice, priorPrice);
 
   return (
-    <span data-direction={direction} className={className}>
-      <CaretUp weight="fill" className={styles.caret} />
+    <ChangeValue direction={direction} className={className}>
       {numberFormatter.format(
-        (100 * Math.abs(currentPrice - priorPrice)) / currentPrice,
+        (100 * Math.abs(currentPrice - priorPrice)) / priorPrice,
       )}
       %
-    </span>
+    </ChangeValue>
   );
 };
 

+ 1 - 1
apps/insights/src/components/ChangePercent/index.module.scss → apps/insights/src/components/ChangeValue/index.module.scss

@@ -1,6 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
-.changePercent {
+.changeValue {
   transition: color 100ms linear;
   display: flex;
   flex-flow: row nowrap;

+ 46 - 0
apps/insights/src/components/ChangeValue/index.tsx

@@ -0,0 +1,46 @@
+import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+
+const SKELETON_WIDTH = 15;
+
+type OwnProps =
+  | { isLoading: true; skeletonWidth?: number | undefined }
+  | {
+      isLoading?: false;
+      direction: "up" | "down" | "flat";
+    };
+
+type Props = Omit<ComponentProps<"span">, keyof OwnProps> & OwnProps;
+
+export const ChangeValue = ({ className, children, ...props }: Props) => {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const { isLoading, ...propsWithoutIsLoading } = props;
+  return (
+    <span
+      className={clsx(styles.changeValue, className)}
+      {...(!props.isLoading && { "data-direction": props.direction })}
+      {...propsWithoutIsLoading}
+    >
+      <Contents {...props}>{children}</Contents>
+    </span>
+  );
+};
+
+const Contents = (props: Props) => {
+  if (props.isLoading) {
+    return <Skeleton width={props.skeletonWidth ?? SKELETON_WIDTH} />;
+  } else if (props.direction === "flat") {
+    return "-";
+  } else {
+    return (
+      <>
+        <CaretUp weight="fill" className={styles.caret} />
+        {props.children}
+      </>
+    );
+  }
+};

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

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

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

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

+ 34 - 0
apps/insights/src/components/Meter/index.module.scss

@@ -0,0 +1,34 @@
+@use "@pythnetwork/component-library/theme";
+
+.meter {
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(2);
+
+  .labels {
+    display: flex;
+    flex-flow: row nowrap;
+    justify-content: space-between;
+    align-items: center;
+
+    @include theme.text("base", "medium");
+  }
+
+  .score {
+    height: theme.spacing(3);
+    width: 100%;
+    border-radius: theme.border-radius("full");
+    position: relative;
+    display: inline-block;
+    background-color: theme.color("button", "outline", "background", "hover");
+
+    .fill {
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      border-radius: theme.border-radius("full");
+      background: theme.color("chart", "series", "primary");
+    }
+  }
+}

+ 35 - 0
apps/insights/src/components/Meter/index.tsx

@@ -0,0 +1,35 @@
+"use client";
+
+import { Meter as MeterComponent } from "@pythnetwork/component-library/unstyled/Meter";
+import type { ComponentProps, ReactNode } from "react";
+
+import styles from "./index.module.scss";
+
+type OwnProps = {
+  label: string;
+  startLabel?: ReactNode | undefined;
+  endLabel?: ReactNode | undefined;
+};
+type Props = Omit<ComponentProps<typeof MeterComponent>, keyof OwnProps> &
+  OwnProps;
+
+export const Meter = ({ label, startLabel, endLabel, ...props }: Props) => (
+  <MeterComponent aria-label={label} {...props}>
+    {({ percentage }) => (
+      <div className={styles.meter}>
+        {(startLabel !== undefined || endLabel !== undefined) && (
+          <div className={styles.labels}>
+            {startLabel ?? <div />}
+            {endLabel ?? <div />}
+          </div>
+        )}
+        <div className={styles.score}>
+          <div
+            className={styles.fill}
+            style={{ width: `${percentage.toString()}%` }}
+          />
+        </div>
+      </div>
+    )}
+  </MeterComponent>
+);

+ 25 - 57
apps/insights/src/components/PriceFeed/price-components-card.tsx → apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -2,31 +2,33 @@
 
 import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
-import { Switch } from "@pythnetwork/component-library/Switch";
 import {
   type RowConfig,
   type SortDescriptor,
   Table,
 } from "@pythnetwork/component-library/Table";
-import { type ReactNode, Suspense, useMemo, useState } from "react";
+import { type ReactNode, Suspense, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
+import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
 
-const PUBLISHER_SCORE_WIDTH = 24;
+const SCORE_WIDTH = 24;
 
 type Props = {
   className?: string | undefined;
+  toolbar?: ReactNode;
+  defaultSort: string;
+  defaultDescending?: boolean | undefined;
   priceComponents: PriceComponent[];
   nameLoadingSkeleton: ReactNode;
-  slug: string;
 };
 
 type PriceComponent = {
   id: string;
-  publisherNameAsString: string | undefined;
+  nameAsString: string | undefined;
   score: number;
   name: ReactNode;
   uptimeScore: number;
@@ -34,39 +36,22 @@ type PriceComponent = {
   deviationScore: number;
   stalledPenalty: number;
   stalledScore: number;
-  isTest: boolean;
 };
 
-export const PriceComponentsCard = ({
-  priceComponents,
-  slug,
-  ...props
-}: Props) => (
+export const PriceComponentsCard = ({ priceComponents, ...props }: Props) => (
   <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPriceComponentsCard
-      priceComponents={priceComponents}
-      slug={slug}
-      {...props}
-    />
+    <ResolvedPriceComponentsCard priceComponents={priceComponents} {...props} />
   </Suspense>
 );
 
 const ResolvedPriceComponentsCard = ({
   priceComponents,
-  slug,
+  defaultSort,
+  defaultDescending,
   ...props
 }: Props) => {
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const [includeTestComponents, setIncludeTestComponents] = useState(false);
-
-  const filteredPriceComponents = useMemo(
-    () =>
-      includeTestComponents
-        ? priceComponents
-        : priceComponents.filter((component) => !component.isTest),
-    [includeTestComponents, priceComponents],
-  );
 
   const {
     search,
@@ -82,11 +67,11 @@ const ResolvedPriceComponentsCard = ({
     numPages,
     mkPageLink,
   } = useQueryParamFilterPagination(
-    filteredPriceComponents,
+    priceComponents,
     (priceComponent, search) =>
       filter.contains(priceComponent.id, search) ||
-      (priceComponent.publisherNameAsString !== undefined &&
-        filter.contains(priceComponent.publisherNameAsString, search)),
+      (priceComponent.nameAsString !== undefined &&
+        filter.contains(priceComponent.nameAsString, search)),
     (a, b, { column, direction }) => {
       switch (column) {
         case "score":
@@ -117,10 +102,7 @@ const ResolvedPriceComponentsCard = ({
         case "name": {
           return (
             (direction === "descending" ? -1 : 1) *
-            collator.compare(
-              a.publisherNameAsString ?? a.id,
-              b.publisherNameAsString ?? b.id,
-            )
+            collator.compare(a.nameAsString ?? a.id, b.nameAsString ?? b.id)
           );
         }
 
@@ -131,8 +113,8 @@ const ResolvedPriceComponentsCard = ({
     },
     {
       defaultPageSize: 20,
-      defaultSort: "score",
-      defaultDescending: true,
+      defaultSort,
+      defaultDescending: defaultDescending ?? false,
     },
   );
 
@@ -150,10 +132,9 @@ const ResolvedPriceComponentsCard = ({
           ...data
         }) => ({
           id,
-          href: `/price-feeds/${slug}/price-components/${id}`,
           data: {
             ...data,
-            score: <Score score={score} width={PUBLISHER_SCORE_WIDTH} />,
+            score: <Score score={score} width={SCORE_WIDTH} />,
             uptimeScore: (
               <FormattedNumber
                 value={uptimeScore}
@@ -188,13 +169,11 @@ const ResolvedPriceComponentsCard = ({
           },
         }),
       ),
-    [paginatedItems, slug],
+    [paginatedItems],
   );
 
   return (
     <PriceComponentsCardContents
-      includeTestComponents={includeTestComponents}
-      setIncludeTestComponents={setIncludeTestComponents}
       numResults={numResults}
       search={search}
       sortDescriptor={sortDescriptor}
@@ -214,14 +193,12 @@ const ResolvedPriceComponentsCard = ({
 
 type PriceComponentsCardProps = Pick<
   Props,
-  "className" | "nameLoadingSkeleton"
+  "className" | "nameLoadingSkeleton" | "toolbar"
 > &
   (
     | { isLoading: true }
     | {
         isLoading?: false;
-        includeTestComponents: boolean;
-        setIncludeTestComponents: (newValue: boolean) => void;
         numResults: number;
         search: string;
         sortDescriptor: SortDescriptor;
@@ -248,23 +225,13 @@ type PriceComponentsCardProps = Pick<
 const PriceComponentsCardContents = ({
   className,
   nameLoadingSkeleton,
+  toolbar,
   ...props
 }: PriceComponentsCardProps) => (
   <Card
     className={className}
     title="Price components"
-    toolbar={
-      <Switch
-        {...(props.isLoading
-          ? { isPending: true }
-          : {
-              isSelected: props.includeTestComponents,
-              onChange: props.setIncludeTestComponents,
-            })}
-      >
-        Show test components
-      </Switch>
-    }
+    toolbar={toolbar}
     {...(!props.isLoading && {
       footer: (
         <Paginator
@@ -283,13 +250,14 @@ const PriceComponentsCardContents = ({
       label="Price components"
       fill
       rounded
+      stickyHeader={rootStyles.headerHeight}
       columns={[
         {
           id: "score",
           name: "SCORE",
           alignment: "center",
-          width: PUBLISHER_SCORE_WIDTH,
-          loadingSkeleton: <Score isLoading width={PUBLISHER_SCORE_WIDTH} />,
+          width: SCORE_WIDTH,
+          loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
           allowsSorting: true,
         },
         {

+ 5 - 5
apps/insights/src/components/PriceFeed/layout.tsx

@@ -13,7 +13,6 @@ import type { ReactNode } from "react";
 import styles from "./layout.module.scss";
 import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
-import { TabPanel, TabRoot, Tabs } from "./tabs";
 import { toHex } from "../../hex";
 import { getData } from "../../services/pyth";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
@@ -25,6 +24,7 @@ import {
   LiveValue,
 } from "../LivePrices";
 import { PriceFeedTag } from "../PriceFeedTag";
+import { TabPanel, TabRoot, Tabs } from "../Tabs";
 
 type Props = {
   children: ReactNode;
@@ -86,7 +86,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
               <Button variant="outline" size="sm" beforeIcon={ListDashes}>
                 Reference Data
               </Button>
-              <Drawer title="Reference Data">
+              <Drawer fill title="Reference Data">
                 <ReferenceData feed={feed} />
               </Drawer>
             </DrawerTrigger>
@@ -149,14 +149,14 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
       <TabRoot>
         <Tabs
           label="Price Feed Navigation"
-          slug={slug}
+          prefix={`/price-feeds/${slug}`}
           items={[
             { segment: undefined, children: "Chart" },
             {
-              segment: "price-components",
+              segment: "publishers",
               children: (
                 <div className={styles.priceComponentsTabLabel}>
-                  <span>Price Components</span>
+                  <span>Publishers</span>
                   <Badge size="xs" style="filled" variant="neutral">
                     <LiveValue feed={feed} field="numComponentPrices" />
                   </Badge>

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

@@ -1,44 +0,0 @@
-"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>
-  );
-};

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

@@ -1,58 +0,0 @@
-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 { PublisherTag } from "../PublisherTag";
-
-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: ranking.final_score,
-          isTest: ranking.cluster === "pythtest-conformance",
-          name: (
-            <div className={styles.publisherName}>
-              <PublisherTag publisherKey={ranking.publisher} />
-              {ranking.cluster === "pythtest-conformance" && (
-                <Badge variant="muted" style="filled" size="xs">
-                  test
-                </Badge>
-              )}
-            </div>
-          ),
-          uptimeScore: ranking.uptime_score,
-          deviationPenalty: ranking.deviation_penalty,
-          deviationScore: ranking.deviation_score,
-          stalledPenalty: ranking.stalled_penalty,
-          stalledScore: ranking.stalled_score,
-        }))}
-        nameLoadingSkeleton={<PublisherTag isLoading />}
-      />
-      <PriceComponentDrawer>{children}</PriceComponentDrawer>
-    </>
-  ) : (
-    notFound()
-  );
-};

+ 44 - 0
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -0,0 +1,44 @@
+"use client";
+
+import { Switch } from "@pythnetwork/component-library/Switch";
+import { type ComponentProps, useState, useMemo } from "react";
+
+import { PriceComponentsCard } from "../PriceComponentsCard";
+
+type OwnProps = {
+  priceComponents: (ComponentProps<
+    typeof PriceComponentsCard
+  >["priceComponents"][number] & {
+    isTest: boolean;
+  })[];
+};
+
+type Props = Omit<ComponentProps<typeof PriceComponentsCard>, keyof OwnProps> &
+  OwnProps;
+
+export const PublishersCard = ({ priceComponents, ...props }: Props) => {
+  const [includeTestComponents, setIncludeTestComponents] = useState(false);
+
+  const filteredPriceComponents = useMemo(
+    () =>
+      includeTestComponents
+        ? priceComponents
+        : priceComponents.filter((component) => !component.isTest),
+    [includeTestComponents, priceComponents],
+  );
+
+  return (
+    <PriceComponentsCard
+      priceComponents={filteredPriceComponents}
+      toolbar={
+        <Switch
+          isSelected={includeTestComponents}
+          onChange={setIncludeTestComponents}
+        >
+          Show test components
+        </Switch>
+      }
+      {...props}
+    />
+  );
+};

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


+ 53 - 0
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -0,0 +1,53 @@
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
+import { notFound } from "next/navigation";
+
+import { PublishersCard } from "./publishers-card";
+import styles from "./publishers.module.scss";
+import { getRankings } from "../../services/clickhouse";
+import { getData } from "../../services/pyth";
+import { PublisherTag } from "../PublisherTag";
+
+type Props = {
+  params: Promise<{
+    slug: string;
+  }>;
+};
+
+export const Publishers = async ({ 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 ? (
+    <PublishersCard
+      defaultSort="score"
+      defaultDescending
+      priceComponents={rankings.map((ranking) => ({
+        id: ranking.publisher,
+        nameAsString: lookupPublisher(ranking.publisher)?.name,
+        score: ranking.final_score,
+        isTest: ranking.cluster === "pythtest-conformance",
+        name: (
+          <div className={styles.publisherName}>
+            <PublisherTag publisherKey={ranking.publisher} />
+            {ranking.cluster === "pythtest-conformance" && (
+              <Badge variant="muted" style="filled" size="xs">
+                test
+              </Badge>
+            )}
+          </div>
+        ),
+        uptimeScore: ranking.uptime_score,
+        deviationPenalty: ranking.deviation_penalty,
+        deviationScore: ranking.deviation_score,
+        stalledPenalty: ranking.stalled_penalty,
+        stalledScore: ranking.stalled_score,
+      }))}
+      nameLoadingSkeleton={<PublisherTag isLoading />}
+    />
+  ) : (
+    notFound()
+  );
+};

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

@@ -107,6 +107,7 @@ export const ReferenceData = ({ feed }: Props) => {
     <Table
       label="Reference Data"
       fill
+      stickyHeader
       className={styles.referenceData ?? ""}
       columns={[
         {

+ 2 - 0
apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx

@@ -36,6 +36,7 @@ export const AssetClassesDrawer = ({
     <DrawerTrigger>
       {children}
       <Drawer
+        fill
         title={
           <>
             <span>Asset Classes</span>
@@ -109,6 +110,7 @@ const AssetClassTable = ({
   return (
     <Table
       fill
+      stickyHeader
       label="Asset Classes"
       columns={[
         {

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

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

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

@@ -11,7 +11,7 @@ import {
 } from "@pythnetwork/component-library/Card";
 import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
-import { type ElementType } from "react";
+import type { ElementType } from "react";
 
 import { AssetClassesDrawer } from "./asset-classes-drawer";
 import { ComingSoonList } from "./coming-soon-list";
@@ -94,6 +94,7 @@ export const PriceFeeds = async () => {
                 Show all
               </Button>
               <Drawer
+                fill
                 className={styles.comingSoonCard ?? ""}
                 title={
                   <>

+ 2 - 0
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -19,6 +19,7 @@ import { useFilter, useCollator } from "react-aria";
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { SKELETON_WIDTH } from "../LivePrices";
 import { NoResults } from "../NoResults";
+import rootStyles from "../Root/index.module.scss";
 
 type Props = {
   id: string;
@@ -248,6 +249,7 @@ const PriceFeedsCardContents = ({
       rounded
       fill
       label="Price Feeds"
+      stickyHeader={rootStyles.headerHeight}
       columns={[
         {
           id: "priceFeedName",

+ 53 - 0
apps/insights/src/components/Publisher/active-feeds-card.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { useSelectedLayoutSegment } from "next/navigation";
+
+import { FormattedNumber } from "../FormattedNumber";
+import { Meter } from "../Meter";
+
+type Props = {
+  publisherKey: string;
+  activeFeeds: number;
+  totalFeeds: number;
+};
+
+export const ActiveFeedsCard = ({
+  publisherKey,
+  activeFeeds,
+  totalFeeds,
+}: Props) => {
+  const layoutSegment = useSelectedLayoutSegment();
+
+  return (
+    <StatCard
+      header1="Active Feeds"
+      header2="Inactive Feeds"
+      stat1={activeFeeds}
+      stat2={totalFeeds - activeFeeds}
+      miniStat1={
+        <>
+          <FormattedNumber
+            maximumFractionDigits={1}
+            value={(100 * activeFeeds) / totalFeeds}
+          />
+          %
+        </>
+      }
+      miniStat2={
+        <>
+          <FormattedNumber
+            maximumFractionDigits={1}
+            value={(100 * (totalFeeds - activeFeeds)) / totalFeeds}
+          />
+          %
+        </>
+      }
+      {...(layoutSegment !== "price-feeds" && {
+        href: `/publishers/${publisherKey}/price-feeds`,
+      })}
+    >
+      <Meter value={activeFeeds} maxValue={totalFeeds} label="Active Feeds" />
+    </StatCard>
+  );
+};

+ 110 - 0
apps/insights/src/components/Publisher/chart-card.tsx

@@ -0,0 +1,110 @@
+"use client";
+
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import dynamic from "next/dynamic";
+import {
+  type ElementType,
+  type ComponentProps,
+  type ReactNode,
+  Suspense,
+  useState,
+  useMemo,
+  useCallback,
+} from "react";
+import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
+import type { CategoricalChartState } from "recharts/types/chart/types";
+
+const LineChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.LineChart),
+  {
+    ssr: false,
+  },
+);
+
+const CHART_HEIGHT = 36;
+
+type OwnProps<T> = {
+  chartClassName?: string | undefined;
+  lineClassName?: string | undefined;
+  data: Point<T>[];
+};
+
+type Point<T> = {
+  x: T;
+  y: number;
+  displayX?: ReactNode | undefined;
+  displayY?: ReactNode | undefined;
+};
+
+type Props<T extends ElementType, U> = Omit<
+  ComponentProps<typeof StatCard<T>>,
+  keyof OwnProps<U> | "children"
+> &
+  OwnProps<U>;
+
+export const ChartCard = <T extends ElementType, U>({
+  chartClassName,
+  lineClassName,
+  data,
+  stat,
+  miniStat,
+  ...props
+}: Props<T, U>) => {
+  const [selectedPoint, setSelectedPoint] = useState<undefined | Point<U>>(
+    undefined,
+  );
+  const selectedDate = useMemo(
+    () =>
+      selectedPoint ? (selectedPoint.displayX ?? selectedPoint.x) : undefined,
+    [selectedPoint],
+  );
+  const domain = useMemo(
+    () => [
+      Math.min(...data.map((point) => point.y)),
+      Math.max(...data.map((point) => point.y)),
+    ],
+    [data],
+  );
+  const updateSelectedPoint = useCallback(
+    (chart: CategoricalChartState) => {
+      setSelectedPoint(
+        (chart.activePayload as { payload: Point<U> }[] | undefined)?.[0]
+          ?.payload,
+      );
+    },
+    [setSelectedPoint],
+  );
+
+  return (
+    <StatCard
+      {...props}
+      stat={selectedPoint ? (selectedPoint.displayY ?? selectedPoint.y) : stat}
+      miniStat={selectedDate ?? miniStat}
+    >
+      <Suspense
+        fallback={<div style={{ height: `${CHART_HEIGHT.toString()}px` }} />}
+      >
+        <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
+          <LineChart
+            className={chartClassName ?? ""}
+            data={data}
+            onMouseEnter={updateSelectedPoint}
+            onMouseMove={updateSelectedPoint}
+            onMouseLeave={updateSelectedPoint}
+          >
+            <Tooltip content={() => <></>} />
+            <Line
+              type="monotone"
+              dataKey="y"
+              className={lineClassName ?? ""}
+              stroke="currentColor"
+              dot={false}
+            />
+            <XAxis dataKey="date" hide />
+            <YAxis hide domain={domain} />
+          </LineChart>
+        </ResponsiveContainer>
+      </Suspense>
+    </StatCard>
+  );
+};

+ 24 - 0
apps/insights/src/components/Publisher/get-rankings-with-data.ts

@@ -0,0 +1,24 @@
+import { getPublisherFeeds } from "../../services/clickhouse";
+import { getData } from "../../services/pyth";
+
+export const getRankingsWithData = async (key: string) => {
+  const [data, rankings] = await Promise.all([
+    getData(),
+    getPublisherFeeds(key),
+  ]);
+  const rankingsWithData = rankings.map((ranking) => {
+    const feed = data.find((feed) => feed.symbol === ranking.symbol);
+    if (!feed) {
+      throw new NoSuchFeedError(ranking.symbol);
+    }
+    return { ranking, feed };
+  });
+  return rankingsWithData;
+};
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 136 - 0
apps/insights/src/components/Publisher/layout.module.scss

@@ -0,0 +1,136 @@
+@use "@pythnetwork/component-library/theme";
+
+.publisherLayout {
+  .header {
+    @include theme.max-width;
+
+    margin-bottom: theme.spacing(6);
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(8);
+
+    .headerRow,
+    .rightGroup,
+    .stats {
+      display: flex;
+      flex-flow: row nowrap;
+    }
+
+    .headerRow,
+    .rightGroup {
+      align-items: center;
+    }
+
+    .stats {
+      align-items: stretch;
+      gap: theme.spacing(6);
+
+      & > * {
+        flex: 1 1 0px;
+        width: 0;
+      }
+
+      .medianScoreChart svg {
+        cursor: pointer;
+      }
+
+      .publisherRankingExplainButton {
+        margin-top: -#{theme.button-padding("xs", false)};
+        margin-right: -#{theme.button-padding("xs", false)};
+      }
+
+      .primarySparkChartLine {
+        color: theme.color("chart", "series", "primary");
+      }
+
+      .secondarySparkChartLine {
+        color: theme.color("chart", "series", "neutral");
+      }
+
+      .activeDate {
+        color: theme.color("muted");
+      }
+
+      .tokens {
+        display: flex;
+        flex-flow: row nowrap;
+        align-items: center;
+      }
+
+      .oisAllocation[data-is-overallocated] {
+        color: theme.color("states", "error", "base");
+      }
+    }
+
+    .headerRow {
+      justify-content: space-between;
+    }
+
+    .rightGroup {
+      gap: theme.spacing(2);
+    }
+
+    .breadcrumbs {
+      margin-bottom: -#{theme.spacing(2)};
+    }
+  }
+
+  .priceFeedsTabLabel {
+    display: inline-flex;
+    flex-flow: row nowrap;
+    gap: theme.spacing(2);
+    align-items: center;
+  }
+
+  .body {
+    @include theme.max-width;
+
+    padding-top: theme.spacing(6);
+  }
+}
+
+.publisherRankingExplainDescription {
+  margin: 0;
+
+  b {
+    font-weight: theme.font-weight("semibold");
+  }
+}
+
+.oisDrawer {
+  .oisDrawerBody {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    grid-template-rows: repeat(4, max-content);
+    gap: theme.spacing(4);
+
+    .oisMeter {
+      grid-column: span 2 / span 2;
+      margin-bottom: -#{theme.spacing(12)};
+
+      .oisMeterIcon {
+        font-size: theme.spacing(6);
+        margin-bottom: theme.spacing(2);
+      }
+
+      .oisMeterLabel {
+        color: theme.color("heading");
+
+        @include theme.text("xl", "medium");
+      }
+    }
+  }
+
+  .oisDrawerFooter {
+    display: flex;
+    flex-flow: row nowrap;
+    justify-content: space-between;
+    align-items: center;
+  }
+}
+
+.medianScoreDrawerBody {
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(6);
+}

+ 403 - 0
apps/insights/src/components/Publisher/layout.tsx

@@ -0,0 +1,403 @@
+import { BookOpenText } from "@phosphor-icons/react/dist/ssr/BookOpenText";
+import { Browsers } from "@phosphor-icons/react/dist/ssr/Browsers";
+import { Info } from "@phosphor-icons/react/dist/ssr/Info";
+import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
+import { Ranking } from "@phosphor-icons/react/dist/ssr/Ranking";
+import { ShieldChevron } from "@phosphor-icons/react/dist/ssr/ShieldChevron";
+import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
+import { Button } from "@pythnetwork/component-library/Button";
+import { DrawerTrigger, Drawer } from "@pythnetwork/component-library/Drawer";
+import { InfoBox } from "@pythnetwork/component-library/InfoBox";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { notFound } from "next/navigation";
+import type { ReactNode } from "react";
+
+import { ActiveFeedsCard } from "./active-feeds-card";
+import { ChartCard } from "./chart-card";
+import styles from "./layout.module.scss";
+import { MedianScoreHistory } from "./median-score-history";
+import { OisApyHistory } from "./ois-apy-history";
+import {
+  getPublishers,
+  getPublisherFeeds,
+  getPublisherRankingHistory,
+  getPublisherMedianScoreHistory,
+} from "../../services/clickhouse";
+import { getPublisherCaps } from "../../services/hermes";
+import { getTotalFeedCount } from "../../services/pyth";
+import { getPublisherPoolData } from "../../services/staking";
+import { ChangeValue } from "../ChangeValue";
+import { FormattedDate } from "../FormattedDate";
+import { FormattedNumber } from "../FormattedNumber";
+import { FormattedTokens } from "../FormattedTokens";
+import { Meter } from "../Meter";
+import { PublisherKey } from "../PublisherKey";
+import { PublisherTag } from "../PublisherTag";
+import { SemicircleMeter } from "../SemicircleMeter";
+import { TabPanel, TabRoot, Tabs } from "../Tabs";
+import { TokenIcon } from "../TokenIcon";
+
+type Props = {
+  children: ReactNode;
+  params: Promise<{
+    key: string;
+  }>;
+};
+
+export const PublishersLayout = async ({ children, params }: Props) => {
+  const { key } = await params;
+  const [
+    publishers,
+    rankingHistory,
+    medianScoreHistory,
+    totalFeedsCount,
+    oisStats,
+    publisherFeeds,
+  ] = await Promise.all([
+    getPublishers(),
+    getPublisherRankingHistory(key),
+    getPublisherMedianScoreHistory(key),
+    getTotalFeedCount(),
+    getOisStats(key),
+    getPublisherFeeds(key),
+  ]);
+
+  const publisher = publishers.find((publisher) => publisher.key === key);
+
+  const currentRanking = rankingHistory.at(-1);
+  const previousRanking = rankingHistory.at(-2);
+
+  const currentMedianScore = medianScoreHistory.at(-1);
+  const previousMedianScore = medianScoreHistory.at(-2);
+
+  return currentRanking && currentMedianScore && publisher ? (
+    <div className={styles.publisherLayout}>
+      <section className={styles.header}>
+        <div className={styles.headerRow}>
+          <Breadcrumbs
+            className={styles.breadcrumbs ?? ""}
+            label="Breadcrumbs"
+            items={[
+              { href: "/", label: "Home" },
+              { href: "/publishers", label: "Publishers" },
+              { label: <PublisherKey size="sm" publisherKey={key} /> },
+            ]}
+          />
+        </div>
+        <div className={styles.headerRow}>
+          <PublisherTag publisherKey={key} />
+        </div>
+        <section className={styles.stats}>
+          <ChartCard
+            variant="primary"
+            header="Publisher Ranking"
+            lineClassName={styles.primarySparkChartLine}
+            corner={
+              <AlertTrigger>
+                <Button
+                  variant="ghost"
+                  size="xs"
+                  beforeIcon={(props) => <Info weight="fill" {...props} />}
+                  rounded
+                  hideText
+                  className={styles.publisherRankingExplainButton ?? ""}
+                >
+                  Explain Publisher Ranking
+                </Button>
+                <Alert title="Publisher Ranking" icon={<Lightbulb />}>
+                  <p className={styles.publisherRankingExplainDescription}>
+                    Each <b>Publisher</b> receives a <b>Ranking</b> which is
+                    derived from the number of price feeds the <b>Publisher</b>{" "}
+                    is actively publishing.
+                  </p>
+                </Alert>
+              </AlertTrigger>
+            }
+            data={rankingHistory.map(({ timestamp, rank }) => ({
+              x: timestamp,
+              y: rank,
+              displayX: (
+                <span className={styles.activeDate}>
+                  <FormattedDate value={timestamp} />
+                </span>
+              ),
+            }))}
+            stat={currentRanking.rank}
+            {...(previousRanking && {
+              miniStat: (
+                <ChangeValue
+                  direction={getChangeDirection(
+                    currentRanking.rank,
+                    previousRanking.rank,
+                  )}
+                >
+                  {Math.abs(currentRanking.rank - previousRanking.rank)}
+                </ChangeValue>
+              ),
+            })}
+          />
+          <DrawerTrigger>
+            <ChartCard
+              header="Median Score"
+              chartClassName={styles.medianScoreChart}
+              lineClassName={styles.secondarySparkChartLine}
+              corner={<Info weight="fill" />}
+              data={medianScoreHistory.map(({ time, medianScore }) => ({
+                x: time,
+                y: medianScore,
+                displayX: (
+                  <span className={styles.activeDate}>
+                    <FormattedDate value={time} />
+                  </span>
+                ),
+                displayY: (
+                  <FormattedNumber
+                    maximumSignificantDigits={5}
+                    value={medianScore}
+                  />
+                ),
+              }))}
+              stat={
+                <FormattedNumber
+                  maximumSignificantDigits={5}
+                  value={currentMedianScore.medianScore}
+                />
+              }
+              {...(previousMedianScore && {
+                miniStat: (
+                  <ChangeValue
+                    direction={getChangeDirection(
+                      previousMedianScore.medianScore,
+                      currentMedianScore.medianScore,
+                    )}
+                  >
+                    <FormattedNumber
+                      maximumSignificantDigits={2}
+                      value={
+                        (100 *
+                          Math.abs(
+                            currentMedianScore.medianScore -
+                              previousMedianScore.medianScore,
+                          )) /
+                        previousMedianScore.medianScore
+                      }
+                    />
+                    %
+                  </ChangeValue>
+                ),
+              })}
+            />
+            <Drawer
+              title="Median Score"
+              bodyClassName={styles.medianScoreDrawerBody}
+              footer={
+                <Button
+                  variant="solid"
+                  size="sm"
+                  href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
+                  target="_blank"
+                  beforeIcon={BookOpenText}
+                >
+                  Documentation
+                </Button>
+              }
+            >
+              <MedianScoreHistory medianScoreHistory={medianScoreHistory} />
+              <InfoBox icon={<Ranking />} header="Publisher Score">
+                Each price feed a publisher provides has an associated score,
+                which is determined by the component{"'"}s uptime, price
+                deviation, and staleness. This panel shows the median for each
+                score across all price feeds published by this publisher, as
+                well as the overall median score across all those feeds.
+              </InfoBox>
+            </Drawer>
+          </DrawerTrigger>
+          <ActiveFeedsCard
+            publisherKey={key}
+            activeFeeds={publisher.numSymbols}
+            totalFeeds={totalFeedsCount}
+          />
+          <DrawerTrigger>
+            <StatCard
+              header="OIS Pool Allocation"
+              stat={
+                <span
+                  className={styles.oisAllocation}
+                  data-is-overallocated={
+                    Number(oisStats.poolUtilization) > oisStats.maxPoolSize
+                      ? ""
+                      : undefined
+                  }
+                >
+                  <FormattedNumber
+                    maximumFractionDigits={2}
+                    value={
+                      (100 * Number(oisStats.poolUtilization)) /
+                      oisStats.maxPoolSize
+                    }
+                  />
+                  %
+                </span>
+              }
+              corner={<Info weight="fill" />}
+            >
+              <Meter
+                value={Number(oisStats.poolUtilization)}
+                maxValue={oisStats.maxPoolSize}
+                label="OIS Pool"
+                startLabel={
+                  <span className={styles.tokens}>
+                    <TokenIcon />
+                    <span>
+                      <FormattedTokens tokens={oisStats.poolUtilization} />
+                    </span>
+                  </span>
+                }
+                endLabel={
+                  <span className={styles.tokens}>
+                    <TokenIcon />
+                    <span>
+                      <FormattedTokens tokens={BigInt(oisStats.maxPoolSize)} />
+                    </span>
+                  </span>
+                }
+              />
+            </StatCard>
+            <Drawer
+              title="OIS Pool Allocation"
+              className={styles.oisDrawer ?? ""}
+              bodyClassName={styles.oisDrawerBody}
+              footerClassName={styles.oisDrawerFooter}
+              footer={
+                <>
+                  <Button
+                    variant="solid"
+                    size="sm"
+                    href="https://staking.pyth.network"
+                    target="_blank"
+                    beforeIcon={Browsers}
+                  >
+                    Open Staking App
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    href="https://docs.pyth.network/home/oracle-integrity-staking"
+                    target="_blank"
+                    beforeIcon={BookOpenText}
+                  >
+                    Documentation
+                  </Button>
+                </>
+              }
+            >
+              <SemicircleMeter
+                width={420}
+                height={420}
+                value={Number(oisStats.poolUtilization)}
+                maxValue={oisStats.maxPoolSize}
+                className={styles.oisMeter ?? ""}
+                aria-label="OIS Pool Utilization"
+              >
+                <TokenIcon className={styles.oisMeterIcon} />
+                <div className={styles.oisMeterLabel}>OIS Pool</div>
+              </SemicircleMeter>
+              <StatCard
+                header="Total Staked"
+                variant="secondary"
+                nonInteractive
+                stat={
+                  <>
+                    <TokenIcon />
+                    <FormattedTokens tokens={oisStats.poolUtilization} />
+                  </>
+                }
+              />
+              <StatCard
+                header="Pool Capacity"
+                variant="secondary"
+                nonInteractive
+                stat={
+                  <>
+                    <TokenIcon />
+                    <FormattedTokens tokens={BigInt(oisStats.maxPoolSize)} />
+                  </>
+                }
+              />
+              <OisApyHistory apyHistory={oisStats.apyHistory ?? []} />
+              <InfoBox
+                icon={<ShieldChevron />}
+                header="Oracle Integrity Staking (OIS)"
+              >
+                OIS allows anyone to help secure Pyth and protect DeFi. Through
+                decentralized staking rewards and slashing, OIS incentivizes
+                Pyth publishers to maintain high-quality data contributions.
+                PYTH holders can stake to publishers to further reinforce oracle
+                security. Rewards are programmatically distributed to high
+                quality publishers and the stakers supporting them to strengthen
+                oracle integrity.
+              </InfoBox>
+            </Drawer>
+          </DrawerTrigger>
+        </section>
+      </section>
+      <TabRoot>
+        <Tabs
+          label="Price Feed Navigation"
+          prefix={`/publishers/${key}`}
+          items={[
+            { segment: undefined, children: "Performance" },
+            {
+              segment: "price-feeds",
+              children: (
+                <div className={styles.priceFeedsTabLabel}>
+                  <span>Price Feeds</span>
+                  <Badge size="xs" style="filled" variant="neutral">
+                    {publisherFeeds.length}
+                  </Badge>
+                </div>
+              ),
+            },
+          ]}
+        />
+        <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
+      </TabRoot>
+    </div>
+  ) : (
+    notFound()
+  );
+};
+
+const getChangeDirection = (previousValue: number, currentValue: number) => {
+  if (currentValue < previousValue) {
+    return "down";
+  } else if (currentValue > previousValue) {
+    return "up";
+  } else {
+    return "flat";
+  }
+};
+
+const getOisStats = async (key: string) => {
+  const [publisherPoolData, publisherCaps] = await Promise.all([
+    getPublisherPoolData(),
+    getPublisherCaps(),
+  ]);
+
+  const publisher = publisherPoolData.find(
+    (publisher) => publisher.pubkey === key,
+  );
+
+  return {
+    apyHistory: publisher?.apyHistory,
+    poolUtilization:
+      (publisher?.totalDelegation ?? 0n) +
+      (publisher?.totalDelegationDelta ?? 0n),
+    maxPoolSize:
+      publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
+        ({ publisher }) => publisher === key,
+      )?.cap ?? 0,
+  };
+};

+ 177 - 0
apps/insights/src/components/Publisher/median-score-history.module.scss

@@ -0,0 +1,177 @@
+@use "@pythnetwork/component-library/theme";
+
+.medianScoreHistory {
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(6);
+
+  .medianScoreHistoryChart {
+    grid-column: span 2 / span 2;
+    border-radius: theme.border-radius("2xl");
+    border: 1px solid theme.color("border");
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(4);
+
+    .top {
+      display: flex;
+      flex-flow: row nowrap;
+      justify-content: space-between;
+      align-items: flex-start;
+      margin: theme.spacing(4);
+
+      .left {
+        display: flex;
+        flex-flow: column nowrap;
+        gap: theme.spacing(1);
+
+        .header {
+          color: theme.color("heading");
+
+          @include theme.text("sm", "medium");
+        }
+
+        .subheader {
+          color: theme.color("muted");
+
+          @include theme.text("xs", "normal");
+        }
+      }
+    }
+
+    .chart {
+      border-bottom-left-radius: theme.border-radius("2xl");
+      border-bottom-right-radius: theme.border-radius("2xl");
+      overflow: hidden;
+
+      .medianScore,
+      .medianUptimeScore,
+      .medianDeviationScore,
+      .medianStalledScore {
+        transition: opacity 100ms linear;
+        opacity: 0.1;
+      }
+
+      .medianScore {
+        color: theme.color("states", "data", "normal");
+      }
+
+      .medianUptimeScore {
+        color: theme.color("states", "info", "normal");
+      }
+
+      .medianDeviationScore {
+        color: theme.color("states", "lime", "normal");
+      }
+
+      .medianStalledScore {
+        color: theme.color("states", "warning", "normal");
+      }
+    }
+
+    &:not([data-focused-score], [data-hovered-score]) {
+      .medianScore,
+      .medianUptimeScore,
+      .medianDeviationScore,
+      .medianStalledScore {
+        opacity: 1;
+      }
+    }
+
+    &[data-hovered-score="uptime"] {
+      .medianUptimeScore {
+        opacity: 0.7;
+      }
+    }
+
+    &[data-focused-score="uptime"] {
+      .medianUptimeScore {
+        opacity: 1;
+      }
+    }
+
+    &[data-hovered-score="deviation"] {
+      .medianDeviationScore {
+        opacity: 0.7;
+      }
+    }
+
+    &[data-focused-score="deviation"] {
+      .medianDeviationScore {
+        opacity: 1;
+      }
+    }
+
+    &[data-hovered-score="stalled"] {
+      .medianStalledScore {
+        opacity: 0.7;
+      }
+    }
+
+    &[data-focused-score="stalled"] {
+      .medianStalledScore {
+        opacity: 1;
+      }
+    }
+
+    &[data-hovered-score="final"] {
+      .medianScore {
+        opacity: 0.7;
+      }
+    }
+
+    &[data-focused-score="final"] {
+      .medianScore {
+        opacity: 1;
+      }
+    }
+  }
+
+  .rankingBreakdown {
+    .legendCell,
+    .scoreCell {
+      vertical-align: top;
+    }
+
+    .uptimeLegend,
+    .deviationLegend,
+    .stalledLegend,
+    .finalScoreLegend {
+      width: theme.spacing(4);
+      height: theme.spacing(4);
+      border-radius: theme.border-radius("full");
+    }
+
+    .uptimeLegend {
+      background: theme.color("states", "info", "normal");
+    }
+
+    .deviationLegend {
+      background: theme.color("states", "lime", "normal");
+    }
+
+    .stalledLegend {
+      background: theme.color("states", "warning", "normal");
+    }
+
+    .finalScoreLegend {
+      background: theme.color("states", "data", "normal");
+    }
+
+    .metric {
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(2);
+      overflow: hidden;
+
+      .metricDescription {
+        color: theme.color("muted");
+
+        @include theme.text("sm", "normal");
+
+        white-space: normal;
+        line-height: 1.2;
+      }
+    }
+  }
+}

+ 301 - 0
apps/insights/src/components/Publisher/median-score-history.tsx

@@ -0,0 +1,301 @@
+"use client";
+
+import { Card } from "@pythnetwork/component-library/Card";
+import { Table } from "@pythnetwork/component-library/Table";
+import dynamic from "next/dynamic";
+import { Suspense, useState, useCallback, useMemo } from "react";
+import { useDateFormatter, useNumberFormatter } from "react-aria";
+import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
+import type { CategoricalChartState } from "recharts/types/chart/types";
+
+import styles from "./median-score-history.module.scss";
+import { Score } from "../Score";
+
+const LineChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.LineChart),
+  {
+    ssr: false,
+  },
+);
+
+const CHART_HEIGHT = 104;
+
+type Props = {
+  medianScoreHistory: Point[];
+};
+
+type Point = {
+  time: Date;
+  medianScore: number;
+  medianUptimeScore: number;
+  medianDeviationScore: number;
+  medianStalledScore: number;
+};
+
+export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
+  const [selectedPoint, setSelectedPoint] = useState<
+    (typeof medianScoreHistory)[number] | undefined
+  >(undefined);
+  const updateSelectedPoint = useCallback(
+    (chart: CategoricalChartState) => {
+      setSelectedPoint(
+        (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload,
+      );
+    },
+    [setSelectedPoint],
+  );
+  const currentPoint = useMemo(
+    () => selectedPoint ?? medianScoreHistory.at(-1),
+    [selectedPoint, medianScoreHistory],
+  );
+  const dateFormatter = useDateFormatter();
+  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
+
+  const [hoveredScore, setHoveredScore] = useState<FocusedScore>(undefined);
+  const hoverUptime = useCallback(() => {
+    setHoveredScore("uptime");
+  }, [setHoveredScore]);
+  const hoverDeviation = useCallback(() => {
+    setHoveredScore("deviation");
+  }, [setHoveredScore]);
+  const hoverStalled = useCallback(() => {
+    setHoveredScore("stalled");
+  }, [setHoveredScore]);
+  const hoverFinal = useCallback(() => {
+    setHoveredScore("final");
+  }, [setHoveredScore]);
+  const clearHover = useCallback(() => {
+    setHoveredScore(undefined);
+  }, [setHoveredScore]);
+
+  const [focusedScore, setFocusedScore] = useState<FocusedScore>(undefined);
+  const toggleFocusedScore = useCallback(
+    (value: typeof focusedScore) => {
+      setFocusedScore((cur) => (cur === value ? undefined : value));
+    },
+    [setFocusedScore],
+  );
+  const toggleFocusUptime = useCallback(() => {
+    toggleFocusedScore("uptime");
+  }, [toggleFocusedScore]);
+  const toggleFocusDeviation = useCallback(() => {
+    toggleFocusedScore("deviation");
+  }, [toggleFocusedScore]);
+  const toggleFocusStalled = useCallback(() => {
+    toggleFocusedScore("stalled");
+  }, [toggleFocusedScore]);
+  const toggleFocusFinal = useCallback(() => {
+    toggleFocusedScore("final");
+  }, [toggleFocusedScore]);
+
+  return (
+    <div className={styles.medianScoreHistory}>
+      <div
+        className={styles.medianScoreHistoryChart}
+        data-hovered-score={hoveredScore}
+        data-focused-score={focusedScore}
+      >
+        <div className={styles.top}>
+          <div className={styles.left}>
+            <h3 className={styles.header}>Score History</h3>
+            <div className={styles.subheader}>
+              {selectedPoint
+                ? dateFormatter.format(selectedPoint.time)
+                : "Last 30 days"}
+            </div>
+          </div>
+          {currentPoint && (
+            <CurrentValue point={currentPoint} focusedScore={focusedScore} />
+          )}
+        </div>
+        <Suspense
+          fallback={<div style={{ height: `${CHART_HEIGHT.toString()}px` }} />}
+        >
+          <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
+            <LineChart
+              data={medianScoreHistory}
+              className={styles.chart ?? ""}
+              onMouseEnter={updateSelectedPoint}
+              onMouseMove={updateSelectedPoint}
+              onMouseLeave={updateSelectedPoint}
+              margin={{ bottom: 0, left: 0, top: 0, right: 0 }}
+            >
+              <Tooltip content={() => <></>} />
+              <Line
+                type="monotone"
+                dataKey="medianScore"
+                dot={false}
+                className={styles.medianScore ?? ""}
+                stroke="currentColor"
+              />
+              <Line
+                type="monotone"
+                dataKey="medianUptimeScore"
+                dot={false}
+                className={styles.medianUptimeScore ?? ""}
+                stroke="currentColor"
+              />
+              <Line
+                type="monotone"
+                dataKey="medianDeviationScore"
+                dot={false}
+                className={styles.medianDeviationScore ?? ""}
+                stroke="currentColor"
+              />
+              <Line
+                type="monotone"
+                dataKey="medianStalledScore"
+                dot={false}
+                className={styles.medianStalledScore ?? ""}
+                stroke="currentColor"
+              />
+              <XAxis dataKey="time" hide />
+              <YAxis hide />
+            </LineChart>
+          </ResponsiveContainer>
+        </Suspense>
+      </div>
+      <Card
+        title="Score Breakdown"
+        nonInteractive
+        className={styles.rankingBreakdown}
+      >
+        <Table
+          label="Score Breakdown"
+          rounded
+          fill
+          columns={[
+            {
+              id: "legend",
+              name: "",
+              width: 4,
+              className: styles.legendCell ?? "",
+            },
+            {
+              id: "metric",
+              name: "METRIC",
+              isRowHeader: true,
+              alignment: "left",
+            },
+            {
+              id: "score",
+              name: "SCORE",
+              alignment: "right",
+              width: 23,
+              className: styles.scoreCell ?? "",
+            },
+          ]}
+          rows={[
+            {
+              id: "uptime",
+              onHoverStart: hoverUptime,
+              onHoverEnd: clearHover,
+              onAction: toggleFocusUptime,
+              data: {
+                legend: <div className={styles.uptimeLegend} />,
+                metric: (
+                  <Metric
+                    name="Median Uptime"
+                    description="Percentage of time a publisher is available and active"
+                  />
+                ),
+                score: numberFormatter.format(
+                  currentPoint?.medianUptimeScore ?? 0,
+                ),
+              },
+            },
+            {
+              id: "deviation",
+              onHoverStart: hoverDeviation,
+              onHoverEnd: clearHover,
+              onAction: toggleFocusDeviation,
+              data: {
+                legend: <div className={styles.deviationLegend} />,
+                metric: (
+                  <Metric
+                    name="Median Price Deviation"
+                    description="Deviations that occur between a publishers' price and the aggregate price"
+                  />
+                ),
+                score: numberFormatter.format(
+                  currentPoint?.medianDeviationScore ?? 0,
+                ),
+              },
+            },
+            {
+              id: "staleness",
+              onHoverStart: hoverStalled,
+              onHoverEnd: clearHover,
+              onAction: toggleFocusStalled,
+              data: {
+                legend: <div className={styles.stalledLegend} />,
+                metric: (
+                  <Metric
+                    name="Median Staleness"
+                    description="Penalizes publishers reporting the same value for the price"
+                  />
+                ),
+                score: numberFormatter.format(
+                  currentPoint?.medianStalledScore ?? 0,
+                ),
+              },
+            },
+            {
+              id: "final",
+              onHoverStart: hoverFinal,
+              onHoverEnd: clearHover,
+              onAction: toggleFocusFinal,
+              data: {
+                legend: <div className={styles.finalScoreLegend} />,
+                metric: (
+                  <Metric
+                    name="Median Final Score"
+                    description="The aggregate score, calculated by combining the other three score components"
+                  />
+                ),
+                score: numberFormatter.format(currentPoint?.medianScore ?? 0),
+              },
+            },
+          ]}
+        />
+      </Card>
+    </div>
+  );
+};
+
+type FocusedScore = "uptime" | "deviation" | "stalled" | "final" | undefined;
+
+type CurrentValueProps = {
+  point: Point;
+  focusedScore: FocusedScore;
+};
+
+const CurrentValue = ({ point, focusedScore }: CurrentValueProps) => {
+  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
+  switch (focusedScore) {
+    case "uptime": {
+      return numberFormatter.format(point.medianUptimeScore);
+    }
+    case "deviation": {
+      return numberFormatter.format(point.medianDeviationScore);
+    }
+    case "stalled": {
+      return numberFormatter.format(point.medianStalledScore);
+    }
+    default: {
+      return <Score score={point.medianScore} />;
+    }
+  }
+};
+
+type MetricProps = {
+  name: string;
+  description: string;
+};
+
+const Metric = ({ name, description }: MetricProps) => (
+  <div className={styles.metric}>
+    <div className={styles.metricName}>{name}</div>
+    <div className={styles.metricDescription}>{description}</div>
+  </div>
+);

+ 56 - 0
apps/insights/src/components/Publisher/ois-apy-history.module.scss

@@ -0,0 +1,56 @@
+@use "@pythnetwork/component-library/theme";
+
+.oisApyHistory {
+  grid-column: span 2 / span 2;
+  border-radius: theme.border-radius("2xl");
+  padding-top: theme.spacing(4);
+  border: 1px solid theme.color("border");
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(3);
+
+  .oisApyHistoryHeader {
+    color: theme.color("muted");
+
+    @include theme.text("sm", "medium");
+
+    margin: 0 theme.spacing(4);
+  }
+
+  .currentPoint {
+    margin: 0 theme.spacing(4);
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    justify-content: space-between;
+
+    .apy {
+      color: theme.color("heading");
+
+      @include theme.text("2xl", "medium");
+    }
+
+    .date {
+      color: theme.color("muted");
+
+      @include theme.text("sm", "normal");
+    }
+  }
+
+  .chart {
+    border-bottom-left-radius: theme.border-radius("2xl");
+    border-bottom-right-radius: theme.border-radius("2xl");
+    overflow: hidden;
+
+    .chartArea {
+      color: theme.color("button", "primary", "background", "normal");
+
+      :global {
+        // stylelint-disable-next-line selector-class-pattern
+        .recharts-area-area {
+          fill: theme.color("states", "data", "background");
+        }
+      }
+    }
+  }
+}

+ 88 - 0
apps/insights/src/components/Publisher/ois-apy-history.tsx

@@ -0,0 +1,88 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { Suspense, useState, useCallback, useMemo } from "react";
+import { useDateFormatter, useNumberFormatter } from "react-aria";
+import { ResponsiveContainer, Tooltip, Area, XAxis, YAxis } from "recharts";
+import type { CategoricalChartState } from "recharts/types/chart/types";
+
+import styles from "./ois-apy-history.module.scss";
+
+const AreaChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.AreaChart),
+  {
+    ssr: false,
+  },
+);
+
+const CHART_HEIGHT = 104;
+
+type Props = {
+  apyHistory: Point[];
+};
+
+type Point = {
+  date: Date;
+  apy: number;
+};
+
+export const OisApyHistory = ({ apyHistory }: Props) => {
+  const [selectedPoint, setSelectedPoint] = useState<
+    (typeof apyHistory)[number] | undefined
+  >(undefined);
+  const updateSelectedPoint = useCallback(
+    (chart: CategoricalChartState) => {
+      setSelectedPoint(
+        (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload,
+      );
+    },
+    [setSelectedPoint],
+  );
+  const currentPoint = useMemo(
+    () => selectedPoint ?? apyHistory.at(-1),
+    [selectedPoint, apyHistory],
+  );
+  const dateFormatter = useDateFormatter();
+  const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 });
+
+  return (
+    <div className={styles.oisApyHistory}>
+      <h3 className={styles.oisApyHistoryHeader}>APY History</h3>
+      {currentPoint && (
+        <div className={styles.currentPoint}>
+          <span className={styles.apy}>
+            {numberFormatter.format(currentPoint.apy)}%
+          </span>
+          <span className={styles.date}>
+            {dateFormatter.format(currentPoint.date)}
+          </span>
+        </div>
+      )}
+      <Suspense
+        fallback={<div style={{ height: `${CHART_HEIGHT.toString()}px` }} />}
+      >
+        <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
+          <AreaChart
+            data={apyHistory}
+            className={styles.chart ?? ""}
+            onMouseEnter={updateSelectedPoint}
+            onMouseMove={updateSelectedPoint}
+            onMouseLeave={updateSelectedPoint}
+            margin={{ bottom: 0, left: 0, top: 0, right: 0 }}
+          >
+            <Tooltip content={() => <></>} />
+            <Area
+              type="monotone"
+              dataKey="apy"
+              dot={false}
+              className={styles.chartArea ?? ""}
+              stroke="currentColor"
+            />
+            <XAxis dataKey="date" hide />
+            <YAxis hide />
+          </AreaChart>
+        </ResponsiveContainer>
+      </Suspense>
+    </div>
+  );
+};

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

@@ -0,0 +1,12 @@
+@use "@pythnetwork/component-library/theme";
+
+.performance {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: theme.spacing(12) theme.spacing(6);
+  align-items: flex-start;
+
+  > *:first-child {
+    grid-column: span 2 / span 2;
+  }
+}

+ 188 - 0
apps/insights/src/components/Publisher/performance.tsx

@@ -0,0 +1,188 @@
+import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { Network } from "@phosphor-icons/react/dist/ssr/Network";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Table } from "@pythnetwork/component-library/Table";
+import { notFound } from "next/navigation";
+
+import { getRankingsWithData } from "./get-rankings-with-data";
+import styles from "./performance.module.scss";
+import { getPublishers } from "../../services/clickhouse";
+import { getTotalFeedCount } from "../../services/pyth";
+import { PriceFeedTag } from "../PriceFeedTag";
+import { PublisherTag } from "../PublisherTag";
+import { Ranking } from "../Ranking";
+import { Score } from "../Score";
+
+const PUBLISHER_SCORE_WIDTH = 24;
+
+type Props = {
+  params: Promise<{
+    key: string;
+  }>;
+};
+
+export const Performance = async ({ params }: Props) => {
+  const { key } = await params;
+  const [publishers, rankingsWithData, totalFeeds] = await Promise.all([
+    getPublishers(),
+    getRankingsWithData(key),
+    getTotalFeedCount(),
+  ]);
+  const slicedPublishers = sliceAround(
+    publishers,
+    (publisher) => publisher.key === key,
+    2,
+  );
+
+  return slicedPublishers === undefined ? (
+    notFound()
+  ) : (
+    <div className={styles.performance}>
+      <Card icon={<Broadcast />} title="Publishers Ranking">
+        <Table
+          rounded
+          fill
+          label="Publishers Ranking"
+          columns={[
+            {
+              id: "ranking",
+              name: "RANKING",
+              width: 30,
+            },
+            {
+              id: "name",
+              name: "NAME / ID",
+              isRowHeader: true,
+              alignment: "left",
+            },
+            {
+              id: "activeFeeds",
+              name: "ACTIVE FEEDS",
+              alignment: "center",
+              width: 40,
+            },
+            {
+              id: "inactiveFeeds",
+              name: "INACTIVE FEEDS",
+              alignment: "center",
+              width: 45,
+            },
+            {
+              id: "medianScore",
+              name: "MEDIAN SCORE",
+              alignment: "right",
+              width: PUBLISHER_SCORE_WIDTH,
+            },
+          ]}
+          rows={slicedPublishers.map((publisher) => ({
+            id: publisher.key,
+            data: {
+              ranking: (
+                <Ranking isCurrent={publisher.key === key}>
+                  {publisher.rank}
+                </Ranking>
+              ),
+              activeFeeds: publisher.numSymbols,
+              inactiveFeeds: totalFeeds - publisher.numSymbols,
+              medianScore: (
+                <Score
+                  width={PUBLISHER_SCORE_WIDTH}
+                  score={publisher.medianScore}
+                />
+              ),
+              name: <PublisherTag publisherKey={publisher.key} />,
+            },
+            ...(publisher.key !== key && {
+              href: `/publishers/${publisher.key}`,
+            }),
+          }))}
+        />
+      </Card>
+      <Card icon={<Network />} title="High-performing feeds">
+        <Table
+          rounded
+          fill
+          label="High-performing feeds"
+          columns={feedColumns}
+          rows={getFeedRows(
+            rankingsWithData
+              .filter(({ ranking }) => ranking.final_score > 0.9)
+              .sort((a, b) => b.ranking.final_score - a.ranking.final_score),
+          )}
+        />
+      </Card>
+      <Card icon={<Network />} title="Low-performing feeds">
+        <Table
+          rounded
+          fill
+          label="Low-performing feeds"
+          columns={feedColumns}
+          rows={getFeedRows(
+            rankingsWithData
+              .filter(({ ranking }) => ranking.final_score < 0.7)
+              .sort((a, b) => a.ranking.final_score - b.ranking.final_score),
+          )}
+        />
+      </Card>
+    </div>
+  );
+};
+
+const feedColumns = [
+  {
+    id: "score" as const,
+    name: "SCORE",
+    alignment: "left" as const,
+    width: 40,
+  },
+  {
+    id: "asset" as const,
+    name: "ASSET",
+    isRowHeader: true,
+    alignment: "left" as const,
+    fill: true,
+  },
+  {
+    id: "assetClass" as const,
+    name: "ASSET CLASS",
+    alignment: "right" as const,
+    width: 50,
+  },
+];
+
+const getFeedRows = (
+  rankingsWithData: Awaited<ReturnType<typeof getRankingsWithData>>,
+) =>
+  rankingsWithData.slice(0, 10).map(({ feed, ranking }) => ({
+    id: ranking.symbol,
+    data: {
+      asset: <PriceFeedTag compact feed={feed} />,
+      assetClass: (
+        <Badge variant="neutral" style="outline" size="xs">
+          {feed.product.asset_type.toUpperCase()}
+        </Badge>
+      ),
+      score: (
+        <Score width={PUBLISHER_SCORE_WIDTH} score={ranking.final_score} />
+      ),
+    },
+  }));
+
+const sliceAround = <T,>(
+  arr: T[],
+  predicate: (elem: T) => boolean,
+  count: number,
+): T[] | undefined => {
+  const index = arr.findIndex((item) => predicate(item));
+  if (index === -1) {
+    return undefined;
+  } else {
+    const min = Math.max(
+      0,
+      index - count - Math.max(0, index + count + 1 - arr.length),
+    );
+    const max = Math.min(arr.length, min + count * 2 + 1);
+    return arr.slice(min, max);
+  }
+};

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

@@ -0,0 +1,32 @@
+import { getRankingsWithData } from "./get-rankings-with-data";
+import { PriceComponentsCard } from "../PriceComponentsCard";
+import { PriceFeedTag } from "../PriceFeedTag";
+
+type Props = {
+  params: Promise<{
+    key: string;
+  }>;
+};
+
+export const PriceFeeds = async ({ params }: Props) => {
+  const { key } = await params;
+  const rankingsWithData = await getRankingsWithData(key);
+
+  return (
+    <PriceComponentsCard
+      defaultSort="name"
+      priceComponents={rankingsWithData.map(({ ranking, feed }) => ({
+        id: feed.product.price_account,
+        nameAsString: feed.product.display_symbol,
+        score: ranking.final_score,
+        name: <PriceFeedTag compact feed={feed} />,
+        uptimeScore: ranking.uptime_score,
+        deviationPenalty: ranking.deviation_penalty,
+        deviationScore: ranking.deviation_score,
+        stalledPenalty: ranking.stalled_penalty,
+        stalledScore: ranking.stalled_score,
+      }))}
+      nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
+    />
+  );
+};

+ 9 - 0
apps/insights/src/components/PublisherKey/index.module.scss

@@ -0,0 +1,9 @@
+@use "@pythnetwork/component-library/theme";
+
+.publisherKey {
+  @each $size, $values in theme.$button-sizes {
+    &[data-size="#{$size}"] {
+      margin: 0 -#{theme.button-padding($size, true)};
+    }
+  }
+}

+ 27 - 0
apps/insights/src/components/PublisherKey/index.tsx

@@ -0,0 +1,27 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+import { CopyButton } from "../CopyButton";
+
+type KeyProps = Omit<
+  ComponentProps<typeof CopyButton>,
+  "variant" | "text" | "children"
+> & {
+  publisherKey: string;
+};
+
+export const PublisherKey = ({
+  publisherKey,
+  className,
+  ...props
+}: KeyProps) => (
+  <CopyButton
+    variant="ghost"
+    className={clsx(styles.publisherKey, className)}
+    text={publisherKey}
+    {...props}
+  >
+    {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`}
+  </CopyButton>
+);

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

@@ -11,10 +11,6 @@
     height: theme.spacing(9);
   }
 
-  .key {
-    margin: 0 -#{theme.button-padding("sm", true)};
-  }
-
   .nameAndKey {
     display: flex;
     flex-flow: column nowrap;
@@ -26,7 +22,6 @@
     }
 
     .key {
-      margin: -#{theme.spacing(1)} -#{theme.button-padding("xs", true)};
       margin-bottom: -#{theme.spacing(2)};
     }
   }

+ 6 - 16
apps/insights/src/components/PublisherTag/index.tsx

@@ -5,7 +5,7 @@ import clsx from "clsx";
 import { type ComponentProps, useMemo } from "react";
 
 import styles from "./index.module.scss";
-import { CopyButton } from "../CopyButton";
+import { PublisherKey } from "../PublisherKey";
 
 type Props = { isLoading: true } | { isLoading?: false; publisherKey: string };
 
@@ -32,24 +32,14 @@ export const PublisherTag = (props: Props) => {
           {knownPublisher ? (
             <div className={styles.nameAndKey}>
               <div className={styles.name}>{knownPublisher.name}</div>
-              <CopyButton
-                size="xs"
-                variant="ghost"
+              <PublisherKey
                 className={styles.key ?? ""}
-                text={props.publisherKey}
-              >
-                {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`}
-              </CopyButton>
+                publisherKey={props.publisherKey}
+                size="xs"
+              />
             </div>
           ) : (
-            <CopyButton
-              size="sm"
-              variant="ghost"
-              className={styles.key ?? ""}
-              text={props.publisherKey}
-            >
-              {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`}
-            </CopyButton>
+            <PublisherKey publisherKey={props.publisherKey} size="sm" />
           )}
         </>
       )}

+ 15 - 46
apps/insights/src/components/Publishers/index.module.scss

@@ -36,56 +36,25 @@
         grid-column: span 2 / span 2;
 
         .oisPool {
-          width: 100%;
-          height: theme.spacing(72);
-          overflow: hidden;
-          display: grid;
-          place-content: center;
-          position: relative;
-
-          .oisPoolChart {
-            position: relative;
-            top: theme.spacing(8);
-
-            .bar {
-              fill: theme.color("button", "primary", "background", "normal");
-            }
-
-            .background {
-              fill: theme.color("button", "disabled", "background");
-            }
+          .title {
+            font-size: theme.font-size("sm");
+            font-weight: theme.font-weight("normal");
+            color: theme.color("heading");
+            margin: 0;
           }
 
-          .legend {
-            text-align: center;
-            position: absolute;
-            top: theme.spacing(30);
-            display: flex;
-            width: 100%;
-            flex-flow: column nowrap;
-            align-items: center;
-            gap: theme.spacing(1.5);
+          .poolUsed {
+            margin: 0;
+            color: theme.color("heading");
 
-            .title {
-              font-size: theme.font-size("sm");
-              font-weight: theme.font-weight("normal");
-              color: theme.color("heading");
-              margin: 0;
-            }
-
-            .poolUsed {
-              margin: 0;
-              color: theme.color("heading");
-
-              @include theme.h3;
-            }
+            @include theme.h3;
+          }
 
-            .poolTotal {
-              margin: 0;
-              color: theme.color("muted");
-              font-size: theme.font-size("sm");
-              font-weight: theme.font-weight("normal");
-            }
+          .poolTotal {
+            margin: 0;
+            color: theme.color("muted");
+            font-size: theme.font-size("sm");
+            font-weight: theme.font-weight("normal");
           }
         }
 

+ 32 - 54
apps/insights/src/components/Publishers/index.tsx

@@ -6,17 +6,20 @@ import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
-import { z } from "zod";
 
 import styles from "./index.module.scss";
 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, getData } from "../../services/pyth";
-import { client as stakingClient } from "../../services/staking";
+import { getPublishers } from "../../services/clickhouse";
+import { getPublisherCaps } from "../../services/hermes";
+import { getData } from "../../services/pyth";
+import {
+  getDelState,
+  getClaimableRewards,
+  getDistributedRewards,
+} from "../../services/staking";
 import { FormattedTokens } from "../FormattedTokens";
 import { PublisherTag } from "../PublisherTag";
+import { SemicircleMeter, Label } from "../SemicircleMeter";
 import { TokenIcon } from "../TokenIcon";
 
 const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n;
@@ -104,26 +107,21 @@ export const Publishers = async () => {
               value={Number(oisStats.totalStaked)}
               maxValue={oisStats.maxPoolSize ?? 0}
               className={styles.oisPool ?? ""}
-              chartClassName={styles.oisPoolChart}
-              barClassName={styles.bar}
-              backgroundClassName={styles.background}
             >
-              <div className={styles.legend}>
-                <Label className={styles.title}>PYTH Staking Pool</Label>
-                <p className={styles.poolUsed}>
-                  <FormattedTokens
-                    mode="wholePart"
-                    tokens={oisStats.totalStaked}
-                  />
-                </p>
-                <p className={styles.poolTotal}>
-                  /{" "}
-                  <FormattedTokens
-                    mode="wholePart"
-                    tokens={BigInt(oisStats.maxPoolSize ?? 0)}
-                  />
-                </p>
-              </div>
+              <Label className={styles.title}>PYTH Staking Pool</Label>
+              <p className={styles.poolUsed}>
+                <FormattedTokens
+                  mode="wholePart"
+                  tokens={oisStats.totalStaked}
+                />
+              </p>
+              <p className={styles.poolTotal}>
+                /{" "}
+                <FormattedTokens
+                  mode="wholePart"
+                  tokens={BigInt(oisStats.maxPoolSize ?? 0)}
+                />
+              </p>
             </SemicircleMeter>
             <div className={styles.oisStats}>
               <StatCard
@@ -169,45 +167,25 @@ export const Publishers = async () => {
   );
 };
 
-const getPublishers = async () => {
-  const rows = await clickhouseClient.query({
-    query:
-      "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})",
-    query_params: { cluster: CLUSTER },
-  });
-  const result = await rows.json();
-
-  return publishersSchema.parse(result.data);
-};
-
-const publishersSchema = z.array(
-  z.strictObject({
-    key: z.string(),
-    rank: z.number(),
-    numSymbols: z.number(),
-    medianScore: z.number(),
-  }),
-);
-
 const getTotalFeedCount = async () => {
   const pythData = await getData();
   return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
 };
 
 const getOisStats = async () => {
-  const [poolData, rewardCustodyAccount, publisherCaps] = await Promise.all([
-    stakingClient.getPoolDataAccount(),
-    stakingClient.getRewardCustodyAccount(),
-    hermesClient.getLatestPublisherCaps({ parsed: true }),
-  ]);
+  const [delState, claimableRewards, distributedRewards, publisherCaps] =
+    await Promise.all([
+      getDelState(),
+      getClaimableRewards(),
+      getDistributedRewards(),
+      getPublisherCaps(),
+    ]);
 
   return {
     totalStaked:
-      sumDelegations(poolData.delState) + sumDelegations(poolData.selfDelState),
+      sumDelegations(delState.delState) + sumDelegations(delState.selfDelState),
     rewardsDistributed:
-      poolData.claimableRewards +
-      INITIAL_REWARD_POOL_SIZE -
-      rewardCustodyAccount.amount,
+      claimableRewards + INITIAL_REWARD_POOL_SIZE - distributedRewards,
     maxPoolSize: publisherCaps.parsed?.[0]?.publisher_stake_caps
       .map(({ cap }) => cap)
       .reduce((acc, value) => acc + value),

+ 3 - 1
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -16,6 +16,7 @@ import { useFilter, useCollator } from "react-aria";
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { NoResults } from "../NoResults";
 import { Ranking } from "../Ranking";
+import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
 
 const PUBLISHER_SCORE_WIDTH = 24;
@@ -96,7 +97,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
     () =>
       paginatedItems.map(({ id, ranking, medianScore, ...data }) => ({
         id,
-        href: "#",
+        href: `/publishers/${id}`,
         data: {
           ...data,
           ranking: <Ranking>{ranking}</Ranking>,
@@ -200,6 +201,7 @@ const PublishersCardContents = ({
       rounded
       fill
       label="Publishers"
+      stickyHeader={rootStyles.headerHeight}
       columns={[
         {
           id: "ranking",

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

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

+ 11 - 11
apps/insights/src/components/Ranking/index.module.scss

@@ -2,31 +2,31 @@
 
 .ranking {
   height: theme.spacing(6);
-  border-radius: theme.border-radius("md");
+  border-radius: theme.border-radius("full");
   width: 100%;
   display: inline-block;
   text-align: center;
   font-size: theme.font-size("sm");
   font-weight: theme.font-weight("medium");
   line-height: theme.spacing(6);
-  color: light-dark(
-    theme.pallette-color("steel", 800),
-    theme.pallette-color("steel", 300)
-  );
 
   .skeleton {
     width: 100%;
     height: 100%;
-    border-radius: theme.border-radius("md");
+    border-radius: theme.border-radius("full");
   }
 
   .content {
     width: 100%;
     height: 100%;
-    border-radius: theme.border-radius("md");
-    background: light-dark(
-      theme.pallette-color("steel", 200),
-      theme.pallette-color("steel", 700)
-    );
+    border-radius: theme.border-radius("full");
+    border: 1px solid theme.color("border");
+    color: theme.color("heading");
+  }
+
+  &[data-is-current] .content {
+    color: theme.pallette-color("white");
+    background-color: theme.color("button", "primary", "background", "normal");
+    border-color: theme.color("button", "primary", "background", "normal");
   }
 }

+ 7 - 1
apps/insights/src/components/Ranking/index.tsx

@@ -6,17 +6,23 @@ import styles from "./index.module.scss";
 
 type OwnProps = {
   isLoading?: boolean | undefined;
+  isCurrent?: boolean | undefined;
 };
 
 type Props = Omit<ComponentProps<"span">, keyof OwnProps> & OwnProps;
 
 export const Ranking = ({
   isLoading,
+  isCurrent,
   className,
   children,
   ...props
 }: Props) => (
-  <span className={clsx(styles.ranking, className)} {...props}>
+  <span
+    className={clsx(styles.ranking, className)}
+    data-is-current={isCurrent ? "" : undefined}
+    {...props}
+  >
     {isLoading ? (
       <Skeleton fill className={styles.skeleton} />
     ) : (

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

@@ -2,6 +2,11 @@
 
 $header-height: theme.spacing(20);
 
+:export {
+  // stylelint-disable-next-line property-no-unknown
+  headerHeight: $header-height;
+}
+
 .root {
   scroll-padding-top: $header-height;
   overflow-x: hidden;

+ 34 - 0
apps/insights/src/components/SemicircleMeter/index.module.scss

@@ -0,0 +1,34 @@
+@use "@pythnetwork/component-library/theme";
+
+.semicircleChart {
+  width: 100%;
+  height: calc(var(--height) * (6 / 7));
+  overflow: hidden;
+  display: grid;
+  place-content: center;
+  position: relative;
+
+  .chart {
+    position: relative;
+    top: theme.spacing(8);
+
+    .bar {
+      fill: theme.color("button", "primary", "background", "normal");
+    }
+
+    .background {
+      fill: theme.color("button", "disabled", "background");
+    }
+  }
+
+  .legend {
+    text-align: center;
+    position: absolute;
+    top: calc(var(--height) * (2 / 5));
+    display: flex;
+    width: 100%;
+    flex-flow: column nowrap;
+    align-items: center;
+    gap: theme.spacing(1.5);
+  }
+}

+ 73 - 0
apps/insights/src/components/SemicircleMeter/index.tsx

@@ -0,0 +1,73 @@
+"use client";
+
+import { Meter } from "@pythnetwork/component-library/unstyled/Meter";
+import clsx from "clsx";
+import dynamic from "next/dynamic";
+import { type ComponentProps, type CSSProperties, Suspense } from "react";
+import { PolarAngleAxis, RadialBar } from "recharts";
+
+import styles from "./index.module.scss";
+
+export { Label } from "@pythnetwork/component-library/unstyled/Label";
+
+const RadialBarChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.RadialBarChart),
+  {
+    ssr: false,
+  },
+);
+
+type OwnProps = {
+  height: number;
+  width: number;
+};
+
+type Props = Omit<ComponentProps<typeof Meter>, keyof OwnProps> & OwnProps;
+
+export const SemicircleMeter = ({
+  width,
+  height,
+  className,
+  children,
+  ...props
+}: Props) => (
+  <Meter
+    className={clsx(styles.semicircleChart, className)}
+    style={{ "--height": `${height.toString()}px` } as CSSProperties}
+    {...props}
+  >
+    {(...args) => (
+      <>
+        <Suspense>
+          <RadialBarChart
+            data={[{ value: args[0].percentage }]}
+            innerRadius="100%"
+            startAngle={210}
+            endAngle={-30}
+            barSize={16}
+            className={styles.chart ?? ""}
+            {...(width && { width })}
+            {...(height && { height })}
+          >
+            <PolarAngleAxis
+              type="number"
+              domain={[0, 100]}
+              angleAxisId={0}
+              tick={false}
+            />
+            <RadialBar
+              angleAxisId={0}
+              background={{ className: styles.background }}
+              dataKey="value"
+              className={styles.bar ?? ""}
+              cornerRadius={999}
+            />
+          </RadialBarChart>
+        </Suspense>
+        <div className={styles.legend}>
+          {typeof children === "function" ? children(...args) : children}
+        </div>
+      </>
+    )}
+  </Meter>
+);

+ 11 - 10
apps/insights/src/components/PriceFeed/tabs.tsx → apps/insights/src/components/Tabs/index.tsx

@@ -19,7 +19,7 @@ export const TabRoot = (
 };
 
 type TabsProps = Omit<ComponentProps<typeof TabList>, "pathname" | "items"> & {
-  slug: string;
+  prefix: string;
   items: (Omit<
     ComponentProps<typeof TabList>["items"],
     "href" | "id"
@@ -28,16 +28,17 @@ type TabsProps = Omit<ComponentProps<typeof TabList>, "pathname" | "items"> & {
   })[];
 };
 
-export const Tabs = ({ slug, items, ...props }: TabsProps) => {
+export const Tabs = ({ prefix, items, ...props }: TabsProps) => {
   const pathname = usePathname();
-  const mappedItems = useMemo(() => {
-    const prefix = `/price-feeds/${slug}`;
-    return items.map((item) => ({
-      ...item,
-      id: item.segment ?? "",
-      href: item.segment ? `${prefix}/${item.segment}` : prefix,
-    }));
-  }, [items, slug]);
+  const mappedItems = useMemo(
+    () =>
+      items.map((item) => ({
+        ...item,
+        id: item.segment ?? "",
+        href: item.segment ? `${prefix}/${item.segment}` : prefix,
+      })),
+    [items, prefix],
+  );
 
   return <TabList pathname={pathname} items={mappedItems} {...props} />;
 };

+ 2 - 1
apps/insights/src/components/TokenIcon/index.module.scss

@@ -2,7 +2,7 @@
 
 .tokenIcon {
   display: inline-block;
-  background: theme.pallette-color("purple", 100);
+  background: theme.pallette-color("purple", 200);
   color: theme.pallette-color("steel", 950);
   padding: 0.35em;
   border-radius: theme.border-radius("full");
@@ -18,6 +18,7 @@
       position: absolute;
       width: 100%;
       top: -0.05em;
+      left: 0;
     }
   }
 }

+ 1 - 1
apps/insights/src/components/PriceFeeds/layout.tsx → apps/insights/src/components/ZoomLayoutTransition/index.tsx

@@ -8,7 +8,7 @@ type Props = {
   children: ReactNode;
 };
 
-export const PriceFeedsLayout = ({ children }: Props) => (
+export const ZoomLayoutTransition = ({ children }: Props) => (
   <LayoutTransition
     variants={{
       initial: (custom) => ({

+ 3 - 0
apps/insights/src/config/server.ts

@@ -50,3 +50,6 @@ export const CLICKHOUSE = {
   username: process.env.CLICKHOUSE_USERNAME ?? "insights",
   password: demand("CLICKHOUSE_PASSWORD"),
 };
+
+export const SOLANA_RPC =
+  process.env.SOLANA_RPC ?? "https://api.mainnet-beta.solana.com";

+ 185 - 23
apps/insights/src/services/clickhouse.ts

@@ -1,16 +1,50 @@
 import "server-only";
 
 import { createClient } from "@clickhouse/client";
-import { z } from "zod";
+import { z, type ZodSchema, type ZodTypeDef } from "zod";
 
 import { cache } from "../cache";
 import { CLICKHOUSE } from "../config/server";
 
-export const client = createClient(CLICKHOUSE);
+const client = createClient(CLICKHOUSE);
 
-export const getRankings = cache(async (symbol: string) => {
-  const rows = await client.query({
-    query: `
+const ONE_MINUTE_IN_SECONDS = 60;
+const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
+
+export const getPublishers = cache(
+  async () =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          key: z.string(),
+          rank: z.number(),
+          numSymbols: z.number(),
+          medianScore: z.number(),
+        }),
+      ),
+      {
+        query:
+          "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})",
+        query_params: { cluster: "pythnet" },
+      },
+    ),
+  ["publishers"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getRankings = cache(
+  async (symbol: string) =>
+    safeQuery(
+      z.array(
+        rankingSchema.extend({
+          cluster: z.enum(["pythnet", "pythtest-conformance"]),
+          publisher: z.string(),
+        }),
+      ),
+      {
+        query: `
       SELECT
         cluster,
         publisher,
@@ -25,25 +59,153 @@ export const getRankings = cache(async (symbol: string) => {
         final_score
       FROM insights_feed_component_rankings(symbol={symbol: String})
     `,
-    query_params: { symbol },
-  });
-  const result = await rows.json();
+        query_params: { symbol },
+      },
+    ),
+  ["rankings"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
 
-  return rankingsSchema.parse(result.data);
+export const getPublisherFeeds = cache(
+  async (publisherKey: string) =>
+    safeQuery(
+      z.array(
+        rankingSchema.extend({
+          symbol: z.string(),
+        }),
+      ),
+      {
+        query: `
+      SELECT
+        symbol,
+        uptime_score,
+        uptime_rank,
+        deviation_penalty,
+        deviation_score,
+        deviation_rank,
+        stalled_penalty,
+        stalled_score,
+        stalled_rank,
+        final_score
+      FROM insights_feeds_for_publisher(publisherKey={publisherKey: String})
+    `,
+        query_params: { publisherKey },
+      },
+    ),
+  ["publisher-feeds"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+const rankingSchema = z.strictObject({
+  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(),
 });
 
-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(),
-  }),
+export const getYesterdaysPrices = cache(
+  async (symbols: string[]) =>
+    safeQuery(
+      z.array(
+        z.object({
+          symbol: z.string(),
+          price: z.number(),
+        }),
+      ),
+      {
+        query:
+          "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})",
+        query_params: { symbols },
+      },
+    ),
+  ["yesterdays-prices"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getPublisherRankingHistory = cache(
+  async (key: string) =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          timestamp: z.string().transform((value) => new Date(value)),
+          rank: z.number(),
+        }),
+      ),
+      {
+        query: `
+        SELECT * FROM (
+          SELECT timestamp, rank
+          FROM publishers_ranking
+          WHERE publisher = {key: String}
+          AND cluster = 'pythnet'
+          ORDER BY timestamp DESC
+          LIMIT 30
+        )
+        ORDER BY timestamp ASC
+      `,
+        query_params: { key },
+      },
+    ),
+  ["publisher-ranking-history"],
+  { revalidate: ONE_HOUR_IN_SECONDS },
+);
+
+export const getPublisherMedianScoreHistory = cache(
+  async (key: string) =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          time: z.string().transform((value) => new Date(value)),
+          medianScore: z.number(),
+          medianUptimeScore: z.number(),
+          medianDeviationScore: z.number(),
+          medianStalledScore: z.number(),
+        }),
+      ),
+      {
+        query: `
+        SELECT * FROM (
+          SELECT
+            time,
+            medianExact(final_score) AS medianScore,
+            medianExact(uptime_score) AS medianUptimeScore,
+            medianExact(deviation_score) AS medianDeviationScore,
+            medianExact(stalled_score) AS medianStalledScore
+          FROM default.publisher_quality_ranking
+          WHERE publisher = {key: String}
+          AND cluster = 'pythnet'
+          GROUP BY time
+          ORDER BY time DESC
+          LIMIT 30
+        )
+        ORDER BY time ASC
+      `,
+        query_params: { key },
+      },
+    ),
+  ["publisher-median-score-history"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
 );
+
+const safeQuery = async <Output, Def extends ZodTypeDef, Input>(
+  schema: ZodSchema<Output, Def, Input>,
+  query: Omit<Parameters<typeof client.query>[0], "format">,
+) => {
+  const rows = await client.query({ ...query, format: "JSON" });
+  const result = await rows.json();
+
+  return schema.parse(result.data);
+};

+ 14 - 1
apps/insights/src/services/hermes.ts

@@ -2,4 +2,17 @@ import "server-only";
 
 import { HermesClient } from "@pythnetwork/hermes-client";
 
-export const client = new HermesClient("https://hermes.pyth.network");
+import { cache } from "../cache";
+
+const ONE_MINUTE_IN_SECONDS = 60;
+const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
+
+const client = new HermesClient("https://hermes.pyth.network");
+
+export const getPublisherCaps = cache(
+  async () => client.getLatestPublisherCaps({ parsed: true }),
+  ["publisher-caps"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);

+ 25 - 11
apps/insights/src/services/pyth.ts

@@ -10,21 +10,30 @@ import { z } from "zod";
 
 import { cache } from "../cache";
 
-export const CLUSTER = "pythnet";
+const ONE_MINUTE_IN_SECONDS = 60;
+const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
+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),
-    })),
-  );
-});
+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),
+      })),
+    );
+  },
+  ["pyth-data"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
 
 const priceFeedsSchema = z.array(
   z.object({
@@ -57,6 +66,11 @@ const priceFeedsSchema = z.array(
   }),
 );
 
+export const getTotalFeedCount = async () => {
+  const pythData = await getData();
+  return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
+};
+
 export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => {
   const pythConn = new PythConnection(
     connection,

+ 71 - 3
apps/insights/src/services/staking.ts

@@ -1,7 +1,75 @@
 import "server-only";
 
-import { PythStakingClient } from "@pythnetwork/staking-sdk";
+import {
+  PythStakingClient,
+  epochToDate,
+  extractPublisherData,
+} from "@pythnetwork/staking-sdk";
 import { Connection } from "@solana/web3.js";
 
-const connection = new Connection("https://api.mainnet-beta.solana.com");
-export const client = new PythStakingClient({ connection });
+import { cache } from "../cache";
+import { SOLANA_RPC } from "../config/server";
+
+const ONE_MINUTE_IN_SECONDS = 60;
+const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
+
+const connection = new Connection(SOLANA_RPC);
+const client = new PythStakingClient({ connection });
+
+export const getPublisherPoolData = cache(
+  async () => {
+    const poolData = await client.getPoolDataAccount();
+    const publisherData = extractPublisherData(poolData);
+    return publisherData.map(
+      ({ totalDelegation, totalDelegationDelta, pubkey, apyHistory }) => ({
+        totalDelegation,
+        totalDelegationDelta,
+        pubkey: pubkey.toBase58(),
+        apyHistory: apyHistory.map(({ epoch, apy }) => ({
+          date: epochToDate(epoch + 1n),
+          apy,
+        })),
+      }),
+    );
+  },
+  ["publisher-pool-data"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getDelState = cache(
+  async () => {
+    const poolData = await client.getPoolDataAccount();
+    return {
+      delState: poolData.delState,
+      selfDelState: poolData.selfDelState,
+    };
+  },
+  ["ois-del-state"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getClaimableRewards = cache(
+  async () => {
+    const poolData = await client.getPoolDataAccount();
+    return poolData.claimableRewards;
+  },
+  ["ois-claimable-rewards"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getDistributedRewards = cache(
+  async () => {
+    const rewardCustodyAccount = await client.getRewardCustodyAccount();
+    return rewardCustodyAccount.amount;
+  },
+  ["distributed-rewards"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);

+ 6 - 0
apps/insights/stylelint.config.js

@@ -10,6 +10,12 @@ const config = {
           `Expected class selector "${selector}" to be camel-case`,
       },
     ],
+    "selector-pseudo-class-no-unknown": [
+      true,
+      {
+        ignorePseudoClasses: ["global", "export"],
+      },
+    ],
   },
 };
 export default config;

+ 2 - 1
apps/insights/turbo.json

@@ -11,7 +11,8 @@
         "AMPLITUDE_API_KEY",
         "CLICKHOUSE_URL",
         "CLICKHOUSE_USERNAME",
-        "CLICKHOUSE_PASSWORD"
+        "CLICKHOUSE_PASSWORD",
+        "SOLANA_RPC"
       ]
     },
     "fix:lint": {

+ 0 - 1
packages/component-library/package.json

@@ -57,7 +57,6 @@
     "postcss-loader": "catalog:",
     "prettier": "catalog:",
     "react": "catalog:",
-    "react-dom": "catalog:",
     "sass": "catalog:",
     "sass-loader": "catalog:",
     "storybook": "catalog:",

+ 2 - 1
packages/component-library/src/Alert/index.tsx

@@ -40,9 +40,10 @@ export const Alert = ({
         transition: { type: "spring", duration: 0.75, bounce: 0.5 },
       },
       hidden: {
-        y: "100%",
+        y: "calc(100% + 2rem)",
         transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
       },
+      unmounted: { y: "calc(100% + 2rem)" },
     }}
     className={clsx(styles.alert, className)}
     {...props}

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

@@ -3,7 +3,7 @@
 import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
 import { House } from "@phosphor-icons/react/dist/ssr/House";
 import clsx from "clsx";
-import type { ComponentProps } from "react";
+import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
@@ -20,7 +20,7 @@ type OwnProps = {
       href: string;
       label: string;
     }[],
-    { label: string },
+    { label: ReactNode },
   ];
 };
 type Props = Omit<ComponentProps<typeof UnstyledBreadcrumbs>, keyof OwnProps> &
@@ -30,10 +30,7 @@ export const Breadcrumbs = ({ label, className, items, ...props }: Props) => (
   <nav aria-label={label}>
     <UnstyledBreadcrumbs
       className={clsx(styles.breadcrumbs, className)}
-      items={items.map((item) => ({
-        id: "href" in item ? item.href : item.label,
-        ...item,
-      }))}
+      items={items.map((item, i) => ({ id: i, ...item }))}
       {...props}
     >
       {(item) => (

+ 10 - 2
packages/component-library/src/Card/index.tsx

@@ -21,6 +21,7 @@ type OwnProps = {
   title?: ReactNode | undefined;
   toolbar?: ReactNode | ReactNode[] | undefined;
   footer?: ReactNode | undefined;
+  nonInteractive?: boolean | undefined;
 };
 
 export type Props<T extends ElementType> = Omit<
@@ -30,11 +31,18 @@ export type Props<T extends ElementType> = Omit<
   OwnProps;
 
 export const Card = (
-  props: Props<"div"> | Props<typeof Link> | Props<typeof Button>,
+  props:
+    | (Props<"div"> & { nonInteractive?: true })
+    | Props<typeof Link>
+    | Props<typeof Button>,
 ) => {
   const overlayState = use(OverlayTriggerStateContext);
 
-  if (overlayState !== null || "onPress" in props) {
+  if (props.nonInteractive) {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { nonInteractive, ...otherProps } = props;
+    return <div {...cardProps(otherProps)} />;
+  } else if (overlayState !== null || "onPress" in props) {
     return <Button {...cardProps(props)} />;
   } else if ("href" in props) {
     return <Link {...cardProps(props)} />;

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

@@ -31,6 +31,7 @@
       align-items: center;
       color: theme.color("heading");
       flex: none;
+      border-bottom: 1px solid theme.color("border");
 
       .title {
         @include theme.h4;
@@ -44,6 +45,26 @@
     .body {
       flex: 1;
       overflow-y: auto;
+      padding: theme.spacing(6);
+    }
+
+    &[data-fill] {
+      .heading {
+        border: none;
+      }
+
+      .body {
+        padding: 0;
+      }
+    }
+
+    &[data-has-footer] {
+      padding-bottom: 0;
+
+      .footer {
+        border-top: 1px solid theme.color("border");
+        padding: theme.spacing(4);
+      }
     }
   }
 }

+ 21 - 2
packages/component-library/src/Drawer/index.tsx

@@ -15,8 +15,13 @@ const CLOSE_DURATION_IN_S = 0.15;
 export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 
 type OwnProps = {
+  fill?: boolean | undefined;
   title: ReactNode;
   closeHref?: string | undefined;
+  footer?: ReactNode | undefined;
+  headingClassName?: string | undefined;
+  bodyClassName?: string | undefined;
+  footerClassName?: string | undefined;
 };
 
 type Props = Omit<
@@ -30,10 +35,16 @@ export const Drawer = ({
   title,
   closeHref,
   children,
+  fill,
+  footer,
+  headingClassName,
+  bodyClassName,
+  footerClassName,
   ...props
 }: Props) => (
   <ModalDialog
     overlayVariants={{
+      unmounted: { backgroundColor: "#00000000" },
       hidden: { backgroundColor: "#00000000" },
       visible: { backgroundColor: "#00000080" },
     }}
@@ -47,13 +58,18 @@ export const Drawer = ({
         x: "calc(100% + 1rem)",
         transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
       },
+      unmounted: {
+        x: "calc(100% + 1rem)",
+      },
     }}
     className={clsx(styles.drawer, className)}
+    data-has-footer={footer === undefined ? undefined : ""}
+    data-fill={fill ? "" : undefined}
     {...props}
   >
     {(...args) => (
       <>
-        <div className={styles.heading}>
+        <div className={clsx(styles.heading, headingClassName)}>
           <Heading className={styles.title} slot="title">
             {title}
           </Heading>
@@ -70,9 +86,12 @@ export const Drawer = ({
             Close
           </Button>
         </div>
-        <div className={styles.body}>
+        <div className={clsx(styles.body, bodyClassName)}>
           {typeof children === "function" ? children(...args) : children}
         </div>
+        {footer && (
+          <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
+        )}
       </>
     )}
   </ModalDialog>

+ 38 - 0
packages/component-library/src/InfoBox/index.module.scss

@@ -0,0 +1,38 @@
+@use "../theme";
+
+.infoBox {
+  grid-column: span 2 / span 2;
+  background: theme.color("states", "info", "background");
+  padding: theme.spacing(4);
+  border-radius: theme.border-radius("xl");
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(1);
+
+  .icon {
+    flex: none;
+    font-size: theme.spacing(6);
+    color: theme.color("states", "info", "icon");
+  }
+
+  .body {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(2);
+    padding: 0 theme.spacing(2);
+
+    .header {
+      @include theme.text("base", "medium");
+
+      color: theme.color("heading");
+      line-height: theme.spacing(6);
+    }
+
+    .contents {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("paragraph");
+      line-height: theme.spacing(6);
+    }
+  }
+}

+ 19 - 0
packages/component-library/src/InfoBox/index.tsx

@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+import styles from "./index.module.scss";
+
+type Props = {
+  icon: ReactNode;
+  header: ReactNode;
+  children: ReactNode;
+};
+
+export const InfoBox = ({ icon, header, children }: Props) => (
+  <div className={styles.infoBox}>
+    <div className={styles.icon}>{icon}</div>
+    <div className={styles.body}>
+      <h3 className={styles.header}>{header}</h3>
+      <p className={styles.contents}>{children}</p>
+    </div>
+  </div>
+);

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

@@ -106,7 +106,7 @@ export const ModalDialog = ({
       isExiting={animation === "hidden"}
       onAnimationStart={startAnimation}
       onAnimationComplete={endAnimation}
-      initial="hidden"
+      initial="unmounted"
       animate={animation}
       {...(onOpenChange && { onOpenChange })}
       {...(overlayVariants && { variants: overlayVariants })}

+ 36 - 8
packages/component-library/src/StatCard/index.module.scss

@@ -1,15 +1,19 @@
 @use "../theme";
 
 .statCard {
-  height: theme.spacing(22);
-
-  .cardContents {
+  .cardContents,
+  .top {
     display: flex;
     flex-flow: column nowrap;
-    justify-content: space-between;
-    height: 100%;
+    gap: theme.spacing(4);
+  }
+
+  .cardContents {
     padding: theme.spacing(3);
     padding-bottom: theme.spacing(2);
+    justify-content: space-between;
+    height: 100%;
+    width: 100%;
 
     .corner {
       position: absolute;
@@ -18,20 +22,38 @@
       display: flex;
     }
 
-    .header {
+    .header,
+    .dualHeader {
       color: theme.color("muted");
-      text-align: left;
 
       @include theme.text("sm", "medium");
     }
 
-    .bottom {
+    .header {
+      text-align: left;
+    }
+
+    .dualHeader {
+      display: flex;
+      flex-flow: row nowrap;
+      justify-content: space-between;
+      align-items: center;
+    }
+
+    .stats {
       display: flex;
       flex-flow: row nowrap;
       justify-content: space-between;
       align-items: center;
 
       .stat {
+        display: flex;
+        flex-flow: row nowrap;
+        align-items: center;
+        gap: theme.spacing(3);
+      }
+
+      .mainStat {
         @include theme.h3;
 
         color: theme.color("heading");
@@ -42,4 +64,10 @@
       }
     }
   }
+
+  &[data-variant="primary"] {
+    .cardContents .header {
+      color: theme.color("states", "data", "normal");
+    }
+  }
 }

+ 82 - 19
packages/component-library/src/StatCard/index.tsx

@@ -4,32 +4,95 @@ import type { ReactNode, ElementType } from "react";
 import styles from "./index.module.scss";
 import { type Props as CardProps, Card } from "../Card/index.js";
 
-type Props<T extends ElementType> = Omit<
-  CardProps<T>,
-  "title" | "toolbar" | "icon" | "footer"
-> & {
+type OwnPropsSingle = {
   header: ReactNode;
   stat: ReactNode;
   miniStat?: ReactNode | undefined;
   corner?: ReactNode | undefined;
 };
 
+type OwnPropsDual = {
+  header1: ReactNode;
+  header2: ReactNode;
+  stat1: ReactNode;
+  stat2: ReactNode;
+  miniStat1?: ReactNode | undefined;
+  miniStat2?: ReactNode | undefined;
+};
+
+type Props<T extends ElementType> = Omit<
+  CardProps<T>,
+  | keyof OwnPropsSingle
+  | keyof OwnPropsDual
+  | "title"
+  | "toolbar"
+  | "icon"
+  | "footer"
+> &
+  (OwnPropsSingle | OwnPropsDual);
+
 export const StatCard = <T extends ElementType>({
-  header,
-  stat,
-  miniStat,
   className,
-  corner,
+  children,
   ...props
-}: Props<T>) => (
-  <Card className={clsx(styles.statCard, className)} {...props}>
-    <div className={styles.cardContents}>
-      {corner && <div className={styles.corner}>{corner}</div>}
-      <h2 className={styles.header}>{header}</h2>
-      <div className={styles.bottom}>
-        <div className={styles.stat}>{stat}</div>
-        {miniStat && <div className={styles.miniStat}>{miniStat}</div>}
+}: Props<T>) => {
+  const {
+    /* eslint-disable @typescript-eslint/no-unused-vars */
+    header,
+    stat,
+    miniStat,
+    corner,
+    header1,
+    header2,
+    stat1,
+    stat2,
+    miniStat1,
+    miniStat2,
+    /* eslint-enable @typescript-eslint/no-unused-vars */
+    ...cardProps
+  } = props;
+  return (
+    <Card className={clsx(styles.statCard, className)} {...cardProps}>
+      <div className={styles.cardContents}>
+        <div className={styles.top}>
+          {"header" in props ? (
+            <>
+              {props.corner && (
+                <div className={styles.corner}>{props.corner}</div>
+              )}
+              <h2 className={styles.header}>{props.header}</h2>
+              <div className={styles.stats}>
+                <div className={styles.mainStat}>{props.stat}</div>
+                {props.miniStat && (
+                  <div className={styles.miniStat}>{props.miniStat}</div>
+                )}
+              </div>
+            </>
+          ) : (
+            <>
+              <h2 className={styles.dualHeader}>
+                <span>{props.header1}</span>
+                <span>{props.header2}</span>
+              </h2>
+              <div className={styles.stats}>
+                <div className={styles.stat}>
+                  <div className={styles.mainStat}>{props.stat1}</div>
+                  {props.miniStat1 && (
+                    <div className={styles.miniStat}>{props.miniStat1}</div>
+                  )}
+                </div>
+                <div className={styles.stat}>
+                  {props.miniStat2 && (
+                    <div className={styles.miniStat}>{props.miniStat2}</div>
+                  )}
+                  <div className={styles.mainStat}>{props.stat2}</div>
+                </div>
+              </div>
+            </>
+          )}
+        </div>
+        {children && <div className={styles.bottom}>{children}</div>}
       </div>
-    </div>
-  </Card>
-);
+    </Card>
+  );
+};

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

@@ -82,11 +82,9 @@
       color: theme.color("muted");
 
       .cell {
+        position: relative;
         border-bottom: 1px solid theme.color("border");
         font-weight: theme.font-weight("medium");
-        position: sticky;
-        top: 0;
-        z-index: 1;
 
         .divider {
           width: 1px;
@@ -97,6 +95,12 @@
           top: theme.spacing(3);
         }
 
+        &[data-sticky-header] {
+          position: sticky;
+          top: var(--sticky-header-top);
+          z-index: 1;
+        }
+
         &[data-sticky] {
           z-index: 2;
         }
@@ -149,7 +153,8 @@
           outline: theme.spacing(0.5) solid theme.color("focus");
         }
 
-        &[data-href] {
+        &[data-href],
+        &[data-has-action] {
           cursor: pointer;
         }
 
@@ -179,8 +184,11 @@
     }
   }
 
-  &[data-fill] .table {
-    width: 100%;
+  &[data-fill] {
+    &,
+    .table {
+      width: 100%;
+    }
   }
 
   &[data-rounded] {

+ 32 - 9
packages/component-library/src/Table/index.tsx

@@ -24,6 +24,8 @@ export type { SortDescriptor } from "../unstyled/Table/index.js";
 
 type TableProps<T extends string> = ComponentProps<typeof UnstyledTable> & {
   className?: string | undefined;
+  headerCellClassName?: string | undefined;
+  stickyHeader?: boolean | string | undefined;
   fill?: boolean | undefined;
   rounded?: boolean | undefined;
   label: string;
@@ -70,6 +72,8 @@ export const Table = <T extends string>({
   isUpdating,
   renderEmptyState,
   dependencies,
+  headerCellClassName,
+  stickyHeader,
   ...props
 }: TableProps<T>) => (
   <div
@@ -85,7 +89,14 @@ export const Table = <T extends string>({
     <UnstyledTable aria-label={label} className={styles.table ?? ""} {...props}>
       <TableHeader columns={columns} className={styles.tableHeader ?? ""}>
         {(column: ColumnConfig<T>) => (
-          <Column {...cellProps(column)} {...column}>
+          <Column
+            data-sticky-header={stickyHeader === undefined ? undefined : ""}
+            {...column}
+            {...cellProps(column, headerCellClassName, {
+              "--sticky-header-top":
+                typeof stickyHeader === "string" ? stickyHeader : 0,
+            } as CSSProperties)}
+          >
             {({ allowsSorting, sort, sortDirection }) => (
               <>
                 {column.name}
@@ -163,6 +174,7 @@ export const Table = <T extends string>({
             <Row
               className={clsx(styles.row, rowClassName)}
               columns={columns}
+              data-has-action={row.onAction === undefined ? undefined : ""}
               {...row}
             >
               {(column: ColumnConfig<T>) => (
@@ -176,15 +188,26 @@ export const Table = <T extends string>({
   </div>
 );
 
-const cellProps = <T extends string>({
-  alignment,
-  width,
-  fill,
-  sticky,
-}: Pick<ColumnConfig<T>, "alignment" | "width" | "fill" | "sticky">) => ({
-  className: styles.cell ?? "",
+const cellProps = <T extends string>(
+  {
+    className,
+    alignment,
+    width,
+    fill,
+    sticky,
+  }: Pick<
+    ColumnConfig<T>,
+    "alignment" | "width" | "fill" | "sticky" | "className"
+  >,
+  extraClassName?: string | undefined,
+  extraStyle?: CSSProperties,
+) => ({
+  className: clsx(styles.cell, extraClassName, className),
   "data-alignment": alignment ?? "left",
   "data-fill": fill ? "" : undefined,
   "data-sticky": sticky ? "" : undefined,
-  ...(width && { style: { "--width": width } as CSSProperties }),
+  style: {
+    ...extraStyle,
+    ...(width && ({ "--width": width } as CSSProperties)),
+  },
 });

+ 14 - 0
packages/component-library/src/theme.scss

@@ -473,11 +473,15 @@ $color: (
         light-dark(pallette-color("steel", 900), pallette-color("steel", 50)),
     ),
     "info": (
+      "background":
+        light-dark(pallette-color("indigo", 100), pallette-color("indigo", 950)),
       "background-opaque":
         light-dark(
           rgb(from pallette-color("indigo", 200) r g b / 80%),
           rgb(from pallette-color("indigo", 950) r g b / 80%)
         ),
+      "icon":
+        light-dark(pallette-color("indigo", 600), pallette-color("indigo", 500)),
       "normal":
         light-dark(pallette-color("indigo", 600), pallette-color("indigo", 400)),
     ),
@@ -500,6 +504,8 @@ $color: (
     "data": (
       "normal":
         light-dark(pallette-color("violet", 600), pallette-color("violet", 400)),
+      "background":
+        light-dark(pallette-color("violet", 100), pallette-color("violet", 950)),
     ),
   ),
   "focus":
@@ -517,6 +523,14 @@ $color: (
       ),
     ),
   ),
+  "chart": (
+    "series": (
+      "primary":
+        light-dark(pallette-color("violet", 500), pallette-color("violet", 400)),
+      "neutral":
+        light-dark(pallette-color("stone", 500), pallette-color("steel", 300)),
+    ),
+  ),
   "button": (
     "primary": (
       "foreground": pallette-color("white"),

+ 510 - 41
pnpm-lock.yaml

@@ -1616,7 +1616,7 @@ importers:
         version: link:../fonts
       '@react-hookz/web':
         specifier: 'catalog:'
-        version: 24.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 24.0.4(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       clsx:
         specifier: 'catalog:'
         version: 2.1.1
@@ -1625,13 +1625,13 @@ importers:
         version: 3.0.1
       motion:
         specifier: 'catalog:'
-        version: 11.14.4(@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@18.3.1(react@18.3.1))(react@19.0.0)
       react-aria:
         specifier: 'catalog:'
-        version: 3.36.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 3.36.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       react-aria-components:
         specifier: 'catalog:'
-        version: 1.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'
@@ -1647,7 +1647,7 @@ importers:
         version: 3.0.1
       '@phosphor-icons/react':
         specifier: 'catalog:'
-        version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 2.1.7(react-dom@18.3.1(react@19.0.0))(react@19.0.0)
       '@storybook/addon-essentials':
         specifier: 'catalog:'
         version: 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(webpack-sources@3.2.3)
@@ -1659,13 +1659,13 @@ importers:
         version: 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@storybook/blocks':
         specifier: 'catalog:'
-        version: 8.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
+        version: 8.3.5(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@storybook/nextjs':
         specifier: 'catalog:'
-        version: 8.3.5(esbuild@0.22.0)(next@15.1.2(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(type-fest@4.26.1)(typescript@5.6.3)(webpack-hot-middleware@2.26.1)(webpack@5.91.0(esbuild@0.22.0))
+        version: 8.3.5(esbuild@0.22.0)(next@15.1.2(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(sass@1.80.7))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(sass@1.80.7)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(type-fest@4.26.1)(typescript@5.6.3)(webpack-hot-middleware@2.26.1)(webpack@5.91.0(esbuild@0.22.0))
       '@storybook/react':
         specifier: 'catalog:'
-        version: 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
+        version: 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
       '@types/jest':
         specifier: 'catalog:'
         version: 29.5.14
@@ -1686,7 +1686,7 @@ importers:
         version: 29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))
       next:
         specifier: 'catalog:'
-        version: 15.1.2(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7)
+        version: 15.1.2(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(sass@1.80.7)
       postcss:
         specifier: 'catalog:'
         version: 8.4.47
@@ -1699,9 +1699,6 @@ importers:
       react:
         specifier: 'catalog:'
         version: 19.0.0
-      react-dom:
-        specifier: 'catalog:'
-        version: 19.0.0(react@19.0.0)
       sass:
         specifier: 'catalog:'
         version: 1.80.7
@@ -28623,7 +28620,7 @@ snapshots:
       jest: 29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))
       jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
       jest-runner-eslint: 2.2.0(eslint@9.5.0)(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))
-      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.7)
+      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(sass@1.80.7)
       prettier: 3.3.3
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
@@ -28657,7 +28654,7 @@ snapshots:
       jest: 29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))
       jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
       jest-runner-eslint: 2.2.0(eslint@9.9.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))
-      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.7)
+      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(sass@1.80.7)
       prettier: 3.3.3
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
@@ -28691,7 +28688,7 @@ snapshots:
       jest: 29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))
       jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
       jest-runner-eslint: 2.2.0(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))
-      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.7)
+      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(sass@1.80.7)
       prettier: 3.3.3
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
@@ -28725,7 +28722,7 @@ snapshots:
       jest: 29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))
       jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
       jest-runner-eslint: 2.2.0(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))
-      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.7)
+      next: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(sass@1.80.7)
       prettier: 3.3.3
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
@@ -33703,6 +33700,11 @@ snapshots:
 
   '@pedrouid/environment@1.0.1': {}
 
+  '@phosphor-icons/react@2.1.7(react-dom@18.3.1(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@phosphor-icons/react@2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       react: 19.0.0
@@ -34070,6 +34072,21 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/calendar@3.6.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@internationalized/date': 3.6.0
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/calendar': 3.6.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/calendar': 3.5.0(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/calendar@3.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@internationalized/date': 3.6.0
@@ -34100,6 +34117,16 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/collections@3.0.0-alpha.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/ssr': 3.9.7(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+      use-sync-external-store: 1.2.0(react@19.0.0)
+
   '@react-aria/collections@3.0.0-alpha.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/ssr': 3.9.7(react@19.0.0)
@@ -34110,6 +34137,24 @@ snapshots:
       react-dom: 19.0.0(react@19.0.0)
       use-sync-external-store: 1.2.0(react@19.0.0)
 
+  '@react-aria/color@3.0.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/numberfield': 3.11.9(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/slider': 3.7.14(react@19.0.0)
+      '@react-aria/spinbutton': 3.6.10(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/textfield': 3.15.0(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/visually-hidden': 3.8.18(react@19.0.0)
+      '@react-stately/color': 3.8.1(react@19.0.0)
+      '@react-stately/form': 3.1.0(react@19.0.0)
+      '@react-types/color': 3.0.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/color@3.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/i18n': 3.12.4(react@19.0.0)
@@ -34128,6 +34173,26 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/combobox@3.11.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/listbox': 3.13.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/menu': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/overlays': 3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/textfield': 3.15.0(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/combobox': 3.10.1(react@19.0.0)
+      '@react-stately/form': 3.1.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/combobox': 3.13.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/combobox@3.11.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/i18n': 3.12.4(react@19.0.0)
@@ -34148,6 +34213,29 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/datepicker@3.12.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@internationalized/date': 3.6.0
+      '@internationalized/number': 3.6.0
+      '@internationalized/string': 3.2.5
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/form': 3.0.11(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/label': 3.7.13(react@19.0.0)
+      '@react-aria/spinbutton': 3.6.10(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/datepicker': 3.11.0(react@19.0.0)
+      '@react-stately/form': 3.1.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/calendar': 3.5.0(react@19.0.0)
+      '@react-types/datepicker': 3.9.0(react@19.0.0)
+      '@react-types/dialog': 3.5.14(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/datepicker@3.12.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@internationalized/date': 3.6.0
@@ -34171,6 +34259,17 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/dialog@3.5.20(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/overlays': 3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-types/dialog': 3.5.14(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/dialog@3.5.20(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34182,6 +34281,16 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/disclosure@3.0.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/ssr': 3.9.7(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/disclosure': 3.0.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/disclosure@3.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/ssr': 3.9.7(react@19.0.0)
@@ -34192,6 +34301,21 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/dnd@3.8.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@internationalized/string': 3.2.5
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/overlays': 3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/dnd': 3.5.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/dnd@3.8.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@internationalized/string': 3.2.5
@@ -34234,6 +34358,24 @@ snapshots:
       '@swc/helpers': 0.5.15
       react: 19.0.0
 
+  '@react-aria/grid@3.11.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/grid': 3.10.0(react@19.0.0)
+      '@react-stately/selection': 3.18.0(react@19.0.0)
+      '@react-types/checkbox': 3.9.0(react@19.0.0)
+      '@react-types/grid': 3.2.10(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.15
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/grid@3.11.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34252,6 +34394,22 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/gridlist@3.10.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/grid': 3.11.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/list': 3.11.1(react@19.0.0)
+      '@react-stately/tree': 3.8.6(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/gridlist@3.10.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34341,6 +34499,20 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/listbox@3.13.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/label': 3.7.13(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/list': 3.11.1(react@19.0.0)
+      '@react-types/listbox': 3.5.3(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/listbox@3.13.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/interactions': 3.22.5(react@19.0.0)
@@ -34359,6 +34531,25 @@ snapshots:
     dependencies:
       '@swc/helpers': 0.5.13
 
+  '@react-aria/menu@3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/overlays': 3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/menu': 3.9.0(react@19.0.0)
+      '@react-stately/selection': 3.18.0(react@19.0.0)
+      '@react-stately/tree': 3.8.6(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/menu': 3.9.13(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/menu@3.16.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34386,6 +34577,22 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/numberfield@3.11.9(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/spinbutton': 3.6.10(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/textfield': 3.15.0(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/form': 3.1.0(react@19.0.0)
+      '@react-stately/numberfield': 3.9.8(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/numberfield': 3.8.7(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/numberfield@3.11.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/i18n': 3.12.4(react@19.0.0)
@@ -34402,6 +34609,22 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/overlays@3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/ssr': 3.9.7(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/visually-hidden': 3.8.18(react@19.0.0)
+      '@react-stately/overlays': 3.6.12(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/overlays': 3.8.11(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/overlays@3.24.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34454,6 +34677,25 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/select@3.15.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/form': 3.0.11(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/label': 3.7.13(react@19.0.0)
+      '@react-aria/listbox': 3.13.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/menu': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/visually-hidden': 3.8.18(react@19.0.0)
+      '@react-stately/select': 3.6.9(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/select': 3.9.8(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/select@3.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/form': 3.0.11(react@19.0.0)
@@ -34473,6 +34715,18 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/selection@3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/selection': 3.18.0(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/selection@3.21.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34505,6 +34759,17 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/spinbutton@3.6.10(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.15
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/spinbutton@3.6.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/i18n': 3.12.4(react@19.0.0)
@@ -34535,6 +34800,26 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/table@3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/grid': 3.11.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/visually-hidden': 3.8.18(react@19.0.0)
+      '@react-stately/collections': 3.12.0(react@19.0.0)
+      '@react-stately/flags': 3.0.5
+      '@react-stately/table': 3.13.0(react@19.0.0)
+      '@react-types/checkbox': 3.9.0(react@19.0.0)
+      '@react-types/grid': 3.2.10(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@react-types/table': 3.10.3(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/table@3.16.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34555,6 +34840,19 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/tabs@3.9.8(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/tabs': 3.7.0(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@react-types/tabs': 3.3.11(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/tabs@3.9.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/focus': 3.19.0(react@19.0.0)
@@ -34568,6 +34866,21 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@react-aria/tag@3.4.8(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/gridlist': 3.10.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/label': 3.7.13(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/list': 3.11.1(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/tag@3.4.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/gridlist': 3.10.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -34639,6 +34952,19 @@ snapshots:
       '@swc/helpers': 0.5.13
       react: 19.0.0
 
+  '@react-aria/tree@3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/gridlist': 3.10.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/tree': 3.8.6(react@19.0.0)
+      '@react-types/button': 3.10.1(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/tree@3.0.0-beta.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/gridlist': 3.10.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -34670,6 +34996,17 @@ snapshots:
       clsx: 2.1.1
       react: 19.0.0
 
+  '@react-aria/virtualizer@4.1.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/virtualizer': 4.2.0(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-aria/virtualizer@4.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-aria/i18n': 3.12.4(react@19.0.0)
@@ -34691,6 +35028,12 @@ snapshots:
 
   '@react-hookz/deep-equal@1.0.4': {}
 
+  '@react-hookz/web@24.0.4(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
+    dependencies:
+      '@react-hookz/deep-equal': 1.0.4
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   '@react-hookz/web@24.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@react-hookz/deep-equal': 1.0.4
@@ -37227,11 +37570,11 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
-  '@storybook/blocks@8.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))':
+  '@storybook/blocks@8.3.5(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))':
     dependencies:
       '@storybook/csf': 0.1.11
       '@storybook/global': 5.0.0
-      '@storybook/icons': 1.2.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       '@types/lodash': 4.14.191
       color-convert: 2.0.1
       dequal: 2.0.3
@@ -37239,14 +37582,14 @@ snapshots:
       markdown-to-jsx: 7.5.0(react@19.0.0)
       memoizerific: 1.11.3
       polished: 4.3.1
-      react-colorful: 5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      react-colorful: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
       telejson: 7.2.0
       ts-dedent: 2.2.0
       util-deprecate: 1.0.2
     optionalDependencies:
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
 
   '@storybook/builder-webpack5@8.3.5(esbuild@0.22.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)':
     dependencies:
@@ -37340,10 +37683,10 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
-  '@storybook/icons@1.2.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+  '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@19.0.0)':
     dependencies:
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
 
   '@storybook/instrumenter@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))':
     dependencies:
@@ -37356,7 +37699,7 @@ snapshots:
     dependencies:
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
 
-  '@storybook/nextjs@8.3.5(esbuild@0.22.0)(next@15.1.2(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(type-fest@4.26.1)(typescript@5.6.3)(webpack-hot-middleware@2.26.1)(webpack@5.91.0(esbuild@0.22.0))':
+  '@storybook/nextjs@8.3.5(esbuild@0.22.0)(next@15.1.2(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(sass@1.80.7))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(sass@1.80.7)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(type-fest@4.26.1)(typescript@5.6.3)(webpack-hot-middleware@2.26.1)(webpack@5.91.0(esbuild@0.22.0))':
     dependencies:
       '@babel/core': 7.25.8
       '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.25.8)
@@ -37373,8 +37716,8 @@ snapshots:
       '@babel/runtime': 7.25.7
       '@pmmmwh/react-refresh-webpack-plugin': 0.5.15(react-refresh@0.14.2)(type-fest@4.26.1)(webpack-hot-middleware@2.26.1)(webpack@5.91.0(esbuild@0.22.0))
       '@storybook/builder-webpack5': 8.3.5(esbuild@0.22.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
-      '@storybook/preset-react-webpack': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(esbuild@0.22.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
-      '@storybook/react': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
+      '@storybook/preset-react-webpack': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(esbuild@0.22.0)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
+      '@storybook/react': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
       '@storybook/test': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@types/node': 22.8.2
       '@types/semver': 7.5.8
@@ -37384,13 +37727,13 @@ snapshots:
       fs-extra: 11.2.0
       image-size: 1.1.1
       loader-utils: 3.2.1
-      next: 15.1.2(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7)
+      next: 15.1.2(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(sass@1.80.7)
       node-polyfill-webpack-plugin: 2.0.1(webpack@5.91.0(esbuild@0.22.0))
       pnp-webpack-plugin: 1.7.0(typescript@5.6.3)
       postcss: 8.4.47
       postcss-loader: 8.1.1(postcss@8.4.47)(typescript@5.6.3)(webpack@5.91.0(esbuild@0.22.0))
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
       react-refresh: 0.14.2
       resolve-url-loader: 5.0.0
       sass-loader: 13.3.3(sass@1.80.7)(webpack@5.91.0(esbuild@0.22.0))
@@ -37428,10 +37771,10 @@ snapshots:
     dependencies:
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
 
-  '@storybook/preset-react-webpack@8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(esbuild@0.22.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)':
+  '@storybook/preset-react-webpack@8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(esbuild@0.22.0)(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)':
     dependencies:
       '@storybook/core-webpack': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
-      '@storybook/react': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
+      '@storybook/react': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)
       '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.3)(webpack@5.91.0(esbuild@0.22.0))
       '@types/node': 22.8.2
       '@types/semver': 7.5.8
@@ -37440,7 +37783,7 @@ snapshots:
       magic-string: 0.30.12
       react: 19.0.0
       react-docgen: 7.0.3
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
       resolve: 1.22.8
       semver: 7.6.3
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
@@ -37480,19 +37823,19 @@ snapshots:
       react-dom: 18.3.1(react@18.3.1)
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
 
-  '@storybook/react-dom-shim@8.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))':
+  '@storybook/react-dom-shim@8.3.5(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))':
     dependencies:
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
 
-  '@storybook/react@8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)':
+  '@storybook/react@8.3.5(@storybook/test@8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)))(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.6.3)':
     dependencies:
       '@storybook/components': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@storybook/global': 5.0.0
       '@storybook/manager-api': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@storybook/preview-api': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
-      '@storybook/react-dom-shim': 8.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
+      '@storybook/react-dom-shim': 8.3.5(react-dom@18.3.1(react@18.3.1))(react@19.0.0)(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@storybook/theming': 8.3.5(storybook@8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4))
       '@types/escodegen': 0.0.6
       '@types/estree': 0.0.51
@@ -37504,8 +37847,8 @@ snapshots:
       html-tags: 3.3.1
       prop-types: 15.8.1
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
-      react-element-to-jsx-string: 15.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
+      react-element-to-jsx-string: 15.0.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
       semver: 7.6.3
       storybook: 8.3.5(bufferutil@4.0.8)(utf-8-validate@6.0.4)
       ts-dedent: 2.2.0
@@ -47399,6 +47742,16 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(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.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
@@ -52974,6 +53327,15 @@ snapshots:
       '@motionone/utils': 10.18.0
       '@motionone/vue': 10.16.4
 
+  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.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.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.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -53145,7 +53507,7 @@ snapshots:
 
   next-tick@1.1.0: {}
 
-  next@14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.7):
+  next@14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@18.3.1)(sass@1.80.7):
     dependencies:
       '@next/env': 14.2.15
       '@swc/helpers': 0.5.5
@@ -53197,6 +53559,32 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  next@15.1.2(@babel/core@7.25.8)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(sass@1.80.7):
+    dependencies:
+      '@next/env': 15.1.2
+      '@swc/counter': 0.1.3
+      '@swc/helpers': 0.5.15
+      busboy: 1.6.0
+      caniuse-lite: 1.0.30001669
+      postcss: 8.4.31
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+      styled-jsx: 5.1.6(@babel/core@7.25.8)(react@19.0.0)
+    optionalDependencies:
+      '@next/swc-darwin-arm64': 15.1.2
+      '@next/swc-darwin-x64': 15.1.2
+      '@next/swc-linux-arm64-gnu': 15.1.2
+      '@next/swc-linux-arm64-musl': 15.1.2
+      '@next/swc-linux-x64-gnu': 15.1.2
+      '@next/swc-linux-x64-musl': 15.1.2
+      '@next/swc-win32-arm64-msvc': 15.1.2
+      '@next/swc-win32-x64-msvc': 15.1.2
+      sass: 1.80.7
+      sharp: 0.33.5
+    transitivePeerDependencies:
+      - '@babel/core'
+      - babel-plugin-macros
+
   next@15.1.2(@babel/core@7.25.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.80.7):
     dependencies:
       '@next/env': 15.1.2
@@ -55113,6 +55501,43 @@ snapshots:
       minimist: 1.2.8
       strip-json-comments: 2.0.1
 
+  react-aria-components@1.5.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
+    dependencies:
+      '@internationalized/date': 3.6.0
+      '@internationalized/string': 3.2.5
+      '@react-aria/collections': 3.0.0-alpha.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/color': 3.0.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/disclosure': 3.0.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/dnd': 3.8.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/live-announcer': 3.4.1
+      '@react-aria/menu': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/toolbar': 3.0.0-beta.11(react@19.0.0)
+      '@react-aria/tree': 3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/virtualizer': 4.1.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-stately/color': 3.8.1(react@19.0.0)
+      '@react-stately/disclosure': 3.0.0(react@19.0.0)
+      '@react-stately/layout': 4.1.0(react@19.0.0)
+      '@react-stately/menu': 3.9.0(react@19.0.0)
+      '@react-stately/selection': 3.18.0(react@19.0.0)
+      '@react-stately/table': 3.13.0(react@19.0.0)
+      '@react-stately/utils': 3.10.5(react@19.0.0)
+      '@react-stately/virtualizer': 4.2.0(react@19.0.0)
+      '@react-types/color': 3.0.1(react@19.0.0)
+      '@react-types/form': 3.7.8(react@19.0.0)
+      '@react-types/grid': 3.2.10(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      '@react-types/table': 3.10.3(react@19.0.0)
+      '@swc/helpers': 0.5.13
+      client-only: 0.0.1
+      react: 19.0.0
+      react-aria: 3.36.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
+      react-stately: 3.34.0(react@19.0.0)
+      use-sync-external-store: 1.2.0(react@19.0.0)
+
   react-aria-components@1.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
     dependencies:
       '@internationalized/date': 3.6.0
@@ -55150,6 +55575,50 @@ snapshots:
       react-stately: 3.34.0(react@19.0.0)
       use-sync-external-store: 1.2.0(react@19.0.0)
 
+  react-aria@3.36.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
+    dependencies:
+      '@internationalized/string': 3.2.5
+      '@react-aria/breadcrumbs': 3.5.19(react@19.0.0)
+      '@react-aria/button': 3.11.0(react@19.0.0)
+      '@react-aria/calendar': 3.6.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/checkbox': 3.15.0(react@19.0.0)
+      '@react-aria/color': 3.0.2(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/combobox': 3.11.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/datepicker': 3.12.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/dialog': 3.5.20(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/disclosure': 3.0.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/dnd': 3.8.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/focus': 3.19.0(react@19.0.0)
+      '@react-aria/gridlist': 3.10.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/i18n': 3.12.4(react@19.0.0)
+      '@react-aria/interactions': 3.22.5(react@19.0.0)
+      '@react-aria/label': 3.7.13(react@19.0.0)
+      '@react-aria/link': 3.7.7(react@19.0.0)
+      '@react-aria/listbox': 3.13.6(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/menu': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/meter': 3.4.18(react@19.0.0)
+      '@react-aria/numberfield': 3.11.9(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/overlays': 3.24.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/progress': 3.4.18(react@19.0.0)
+      '@react-aria/radio': 3.10.10(react@19.0.0)
+      '@react-aria/searchfield': 3.7.11(react@19.0.0)
+      '@react-aria/select': 3.15.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/selection': 3.21.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/separator': 3.4.4(react@19.0.0)
+      '@react-aria/slider': 3.7.14(react@19.0.0)
+      '@react-aria/ssr': 3.9.7(react@19.0.0)
+      '@react-aria/switch': 3.6.10(react@19.0.0)
+      '@react-aria/table': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/tabs': 3.9.8(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/tag': 3.4.8(react-dom@18.3.1(react@18.3.1))(react@19.0.0)
+      '@react-aria/textfield': 3.15.0(react@19.0.0)
+      '@react-aria/tooltip': 3.7.10(react@19.0.0)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-aria/visually-hidden': 3.8.18(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      react: 19.0.0
+      react-dom: 18.3.1(react@18.3.1)
+
   react-aria@3.36.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
     dependencies:
       '@internationalized/string': 3.2.5
@@ -55199,10 +55668,10 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
-  react-colorful@5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+  react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
     dependencies:
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
 
   react-dev-utils@12.0.1(eslint@9.13.0(jiti@1.21.0))(typescript@4.9.5)(webpack@5.91.0):
     dependencies:
@@ -55284,12 +55753,12 @@ snapshots:
       react: 19.0.0
       scheduler: 0.25.0
 
-  react-element-to-jsx-string@15.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+  react-element-to-jsx-string@15.0.0(react-dom@18.3.1(react@18.3.1))(react@19.0.0):
     dependencies:
       '@base2/pretty-print-object': 1.0.1
       is-plain-object: 5.0.0
       react: 19.0.0
-      react-dom: 19.0.0(react@19.0.0)
+      react-dom: 18.3.1(react@18.3.1)
       react-is: 18.1.0
 
   react-error-overlay@6.0.11: {}