Переглянути джерело

feat(insights): finish price feeds index

Connor Prussin 11 місяців тому
батько
коміт
f2ca681ecd
81 змінених файлів з 3494 додано та 1214 видалено
  1. 2 0
      apps/insights/package.json
  2. 0 1
      apps/insights/src/app/loading.tsx
  3. 0 1
      apps/insights/src/app/price-feeds/layout.ts
  4. 0 1
      apps/insights/src/app/price-feeds/loading.ts
  5. 25 0
      apps/insights/src/app/yesterdays-prices/route.ts
  6. 14 18
      apps/insights/src/components/CopyButton/index.module.scss
  7. 1 4
      apps/insights/src/components/CopyButton/index.tsx
  8. 1 0
      apps/insights/src/components/H1/index.module.scss
  9. 27 0
      apps/insights/src/components/LivePrices/index.module.scss
  10. 202 0
      apps/insights/src/components/LivePrices/index.tsx
  11. 2 3
      apps/insights/src/components/PriceFeeds/asset-classes-card.module.scss
  12. 122 0
      apps/insights/src/components/PriceFeeds/asset-classes-card.tsx
  13. 0 42
      apps/insights/src/components/PriceFeeds/columns.ts
  14. 23 0
      apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.module.scss
  15. 152 0
      apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.tsx
  16. 0 38
      apps/insights/src/components/PriceFeeds/epoch-select.tsx
  17. 11 0
      apps/insights/src/components/PriceFeeds/featured-coming-soon.module.scss
  18. 68 0
      apps/insights/src/components/PriceFeeds/featured-coming-soon.tsx
  19. 53 0
      apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss
  20. 243 0
      apps/insights/src/components/PriceFeeds/featured-recently-added.tsx
  21. 55 16
      apps/insights/src/components/PriceFeeds/index.module.scss
  22. 283 40
      apps/insights/src/components/PriceFeeds/index.tsx
  23. 0 13
      apps/insights/src/components/PriceFeeds/layout.module.scss
  24. 0 19
      apps/insights/src/components/PriceFeeds/layout.tsx
  25. 0 14
      apps/insights/src/components/PriceFeeds/loading.tsx
  26. 17 0
      apps/insights/src/components/PriceFeeds/num-active-feeds.tsx
  27. 9 0
      apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss
  28. 330 0
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  29. 0 13
      apps/insights/src/components/PriceFeeds/prices.module.scss
  30. 0 123
      apps/insights/src/components/PriceFeeds/prices.tsx
  31. 0 153
      apps/insights/src/components/PriceFeeds/results.tsx
  32. 65 0
      apps/insights/src/components/PriceFeeds/use-query.ts
  33. 5 2
      apps/insights/src/components/Publishers/loading.tsx
  34. 14 16
      apps/insights/src/components/Publishers/results.tsx
  35. 5 4
      apps/insights/src/components/Root/footer.module.scss
  36. 39 9
      apps/insights/src/components/Root/header.module.scss
  37. 4 2
      apps/insights/src/components/Root/header.tsx
  38. 16 11
      apps/insights/src/components/Root/index.module.scss
  39. 3 1
      apps/insights/src/components/Root/index.tsx
  40. 12 0
      apps/insights/src/static-data/price-feeds.tsx
  41. 57 0
      apps/insights/src/use-data.ts
  42. 1 0
      flake.nix
  43. 8 2
      packages/component-library/.storybook/preview.tsx
  44. 1 0
      packages/component-library/.storybook/storybook.module.scss
  45. 10 6
      packages/component-library/src/AppTabs/index.module.scss
  46. 53 0
      packages/component-library/src/Badge/index.module.scss
  47. 46 0
      packages/component-library/src/Badge/index.stories.tsx
  48. 41 0
      packages/component-library/src/Badge/index.tsx
  49. 1 1
      packages/component-library/src/Button/index.module.scss
  50. 94 1
      packages/component-library/src/Card/index.module.scss
  51. 53 6
      packages/component-library/src/Card/index.stories.tsx
  52. 73 5
      packages/component-library/src/Card/index.tsx
  53. 42 0
      packages/component-library/src/Drawer/index.module.scss
  54. 38 0
      packages/component-library/src/Drawer/index.stories.tsx
  55. 106 0
      packages/component-library/src/Drawer/index.tsx
  56. 1 0
      packages/component-library/src/Html/base.scss
  57. 6 1
      packages/component-library/src/Html/index.tsx
  58. 11 5
      packages/component-library/src/Link/index.module.scss
  59. 5 0
      packages/component-library/src/Paginator/index.stories.tsx
  60. 18 51
      packages/component-library/src/Paginator/index.tsx
  61. 1 1
      packages/component-library/src/SearchInput/index.module.scss
  62. 2 0
      packages/component-library/src/SearchInput/index.tsx
  63. 90 13
      packages/component-library/src/Select/index.module.scss
  64. 45 5
      packages/component-library/src/Select/index.stories.tsx
  65. 69 23
      packages/component-library/src/Select/index.tsx
  66. 27 0
      packages/component-library/src/Skeleton/index.module.scss
  67. 7 0
      packages/component-library/src/Skeleton/index.stories.tsx
  68. 16 8
      packages/component-library/src/Skeleton/index.tsx
  69. 36 0
      packages/component-library/src/StatCard/index.module.scss
  70. 51 0
      packages/component-library/src/StatCard/index.stories.tsx
  71. 32 0
      packages/component-library/src/StatCard/index.tsx
  72. 30 14
      packages/component-library/src/Table/index.module.scss
  73. 31 0
      packages/component-library/src/Table/index.stories.tsx
  74. 89 69
      packages/component-library/src/Table/index.tsx
  75. 0 32
      packages/component-library/src/TableCard/index.module.scss
  76. 0 51
      packages/component-library/src/TableCard/index.stories.tsx
  77. 0 32
      packages/component-library/src/TableCard/index.tsx
  78. 112 12
      packages/component-library/src/theme.scss
  79. 1 1
      packages/next-root/src/index.tsx
  80. 383 329
      pnpm-lock.yaml
  81. 4 2
      pnpm-workspace.yaml

+ 2 - 0
apps/insights/package.json

@@ -30,6 +30,7 @@
     "@pythnetwork/next-root": "workspace:*",
     "@react-hookz/web": "catalog:",
     "@solana/web3.js": "catalog:",
+    "bs58": "catalog:",
     "clsx": "catalog:",
     "cryptocurrency-icons": "catalog:",
     "framer-motion": "catalog:",
@@ -40,6 +41,7 @@
     "react-aria": "catalog:",
     "react-aria-components": "catalog:",
     "react-dom": "catalog:",
+    "swr": "catalog:",
     "zod": "catalog:"
   },
   "devDependencies": {

+ 0 - 1
apps/insights/src/app/loading.tsx

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

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

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

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

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

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

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

+ 14 - 18
apps/insights/src/components/CopyButton/index.module.scss

@@ -1,16 +1,19 @@
 @use "@pythnetwork/component-library/theme";
 
 .copyButton {
-  margin: -#{theme.spacing(0.5)} -0.5em;
+  margin: -#{theme.spacing(0.5)} -#{theme.spacing(1)};
   display: inline-block;
   white-space: nowrap;
   border-radius: theme.border-radius("md");
-  padding: theme.spacing(0.5) 0.5em;
-  border: none;
+  padding: theme.spacing(0.5) theme.spacing(1);
   background: none;
   cursor: pointer;
-  transition: background-color 100ms linear;
-  outline: none;
+  transition-property: background-color, color, border-color, outline-color;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  border: 1px solid transparent;
+  outline-offset: 0;
+  outline: theme.spacing(1) solid transparent;
 
   .iconContainer {
     position: relative;
@@ -18,18 +21,11 @@
     margin-left: theme.spacing(1);
     display: inline-block;
 
-    .copyIconContainer {
+    .copyIcon {
       opacity: 0.5;
       transition: opacity 100ms linear;
-
-      .copyIcon {
-        width: 1em;
-        height: 1em;
-      }
-
-      .copyIconLabel {
-        @include theme.sr-only;
-      }
+      width: 1em;
+      height: 1em;
     }
 
     .checkIcon {
@@ -50,12 +46,12 @@
   }
 
   &[data-focus-visible] {
-    outline: 1px solid currentcolor;
-    outline-offset: theme.spacing(1);
+    border-color: theme.color("focus");
+    outline-color: theme.color("focus-dim");
   }
 
   &[data-is-copied] .iconContainer {
-    .copyIconContainer {
+    .copyIcon {
       opacity: 0;
     }
 

+ 1 - 4
apps/insights/src/components/CopyButton/index.tsx

@@ -64,10 +64,7 @@ export const CopyButton = ({
             {typeof children === "function" ? children(...args) : children}
           </span>
           <span className={styles.iconContainer}>
-            <span className={styles.copyIconContainer}>
-              <Copy className={styles.copyIcon} />
-              <div className={styles.copyIconLabel}>Copy to clipboard</div>
-            </span>
+            <Copy className={styles.copyIcon} />
             <Check className={styles.checkIcon} />
           </span>
         </>

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

@@ -3,4 +3,5 @@
 .h1 {
   font-size: theme.font-size("2xl");
   font-weight: theme.font-weight("medium");
+  margin: 0;
 }

+ 27 - 0
apps/insights/src/components/LivePrices/index.module.scss

@@ -0,0 +1,27 @@
+@use "@pythnetwork/component-library/theme";
+
+.price {
+  transition: color 100ms linear;
+
+  &[data-direction="up"] {
+    color: theme.color("states", "success", "base");
+  }
+
+  &[data-direction="down"] {
+    color: theme.color("states", "error", "base");
+  }
+}
+
+.confidence {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(2);
+  align-items: center;
+
+  .plusMinus {
+    width: theme.spacing(4);
+    height: theme.spacing(4);
+    display: inline-block;
+    color: theme.color("muted");
+  }
+}

+ 202 - 0
apps/insights/src/components/LivePrices/index.tsx

@@ -0,0 +1,202 @@
+"use client";
+
+import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
+import { useLogger } from "@pythnetwork/app-logger";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { useMap } from "@react-hookz/web";
+import { PublicKey } from "@solana/web3.js";
+import {
+  type ComponentProps,
+  use,
+  createContext,
+  useEffect,
+  useCallback,
+  useState,
+} from "react";
+import { useNumberFormatter } from "react-aria";
+
+import styles from "./index.module.scss";
+import { client, subscribe } from "../../pyth";
+
+export const SKELETON_WIDTH = 20;
+
+const LivePricesContext = createContext<
+  ReturnType<typeof usePriceData> | undefined
+>(undefined);
+
+type Price = {
+  price: number;
+  direction: ChangeDirection;
+  confidence: number;
+};
+
+type ChangeDirection = "up" | "down" | "flat";
+
+type LivePricesProviderProps = Omit<
+  ComponentProps<typeof LivePricesContext>,
+  "value"
+>;
+
+export const LivePricesProvider = ({ ...props }: LivePricesProviderProps) => {
+  const priceData = usePriceData();
+
+  return <LivePricesContext value={priceData} {...props} />;
+};
+
+export const useLivePrice = (account: string) => {
+  const { priceData, addSubscription, removeSubscription } = useLivePrices();
+
+  useEffect(() => {
+    addSubscription(account);
+    return () => {
+      removeSubscription(account);
+    };
+  }, [addSubscription, removeSubscription, account]);
+
+  return priceData.get(account);
+};
+
+export const LivePrice = ({ account }: { account: string }) => {
+  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
+  const price = useLivePrice(account);
+
+  return price === undefined ? (
+    <Skeleton width={SKELETON_WIDTH} />
+  ) : (
+    <span className={styles.price} data-direction={price.direction}>
+      {numberFormatter.format(price.price)}
+    </span>
+  );
+};
+
+export const LiveConfidence = ({ account }: { account: string }) => {
+  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
+  const price = useLivePrice(account);
+
+  return price === undefined ? (
+    <Skeleton width={SKELETON_WIDTH} />
+  ) : (
+    <span className={styles.confidence}>
+      <PlusMinus className={styles.plusMinus} />
+      <span>{numberFormatter.format(price.confidence)}</span>
+    </span>
+  );
+};
+
+const usePriceData = () => {
+  const feedSubscriptions = useMap<string, number>([]);
+  const [feedKeys, setFeedKeys] = useState<string[]>([]);
+  const priceData = useMap<string, Price>([]);
+  const logger = useLogger();
+
+  useEffect(() => {
+    // First, we initialize prices with the last available price.  This way, if
+    // there's any symbol that isn't currently publishing prices (e.g. the
+    // markets are closed), we will still display the last published price for
+    // that symbol.
+    const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
+    if (uninitializedFeedKeys.length > 0) {
+      client
+        .getAssetPricesFromAccounts(
+          uninitializedFeedKeys.map((key) => new PublicKey(key)),
+        )
+        .then((initialPrices) => {
+          for (const [i, price] of initialPrices.entries()) {
+            const key = uninitializedFeedKeys[i];
+            if (key) {
+              priceData.set(key, {
+                price: price.aggregate.price,
+                direction: "flat",
+                confidence: price.aggregate.confidence,
+              });
+            }
+          }
+        })
+        .catch((error: unknown) => {
+          logger.error("Failed to fetch initial prices", error);
+        });
+    }
+
+    // Then, we create a subscription to update prices live.
+    const connection = subscribe(
+      feedKeys.map((key) => new PublicKey(key)),
+      ({ price_account }, { aggregate }) => {
+        if (price_account) {
+          const prevPrice = priceData.get(price_account)?.price;
+          priceData.set(price_account, {
+            price: aggregate.price,
+            direction: getChangeDirection(prevPrice, aggregate.price),
+            confidence: aggregate.confidence,
+          });
+        }
+      },
+    );
+
+    connection.start().catch((error: unknown) => {
+      logger.error("Failed to subscribe to prices", error);
+    });
+    return () => {
+      connection.stop().catch((error: unknown) => {
+        logger.error("Failed to unsubscribe from price updates", error);
+      });
+    };
+  }, [feedKeys, logger, priceData]);
+
+  const addSubscription = useCallback(
+    (key: string) => {
+      const current = feedSubscriptions.get(key) ?? 0;
+      feedSubscriptions.set(key, current + 1);
+      if (current === 0) {
+        setFeedKeys((prev) => [...new Set([...prev, key])]);
+      }
+    },
+    [feedSubscriptions],
+  );
+
+  const removeSubscription = useCallback(
+    (key: string) => {
+      const current = feedSubscriptions.get(key);
+      if (current) {
+        feedSubscriptions.set(key, current - 1);
+        if (current === 1) {
+          setFeedKeys((prev) => prev.filter((elem) => elem !== key));
+        }
+      }
+    },
+    [feedSubscriptions],
+  );
+
+  return {
+    priceData: new Map(priceData),
+    addSubscription,
+    removeSubscription,
+  };
+};
+
+const useLivePrices = () => {
+  const prices = use(LivePricesContext);
+  if (prices === undefined) {
+    throw new LivePricesProviderNotInitializedError();
+  }
+  return prices;
+};
+
+class LivePricesProviderNotInitializedError extends Error {
+  constructor() {
+    super("This component must be a child of <LivePricesProvider>");
+    this.name = "LivePricesProviderNotInitializedError";
+  }
+}
+
+const getChangeDirection = (
+  prevPrice: number | undefined,
+  price: number,
+): ChangeDirection => {
+  if (prevPrice === undefined || prevPrice === price) {
+    return "flat";
+  } else if (prevPrice < price) {
+    return "up";
+  } else {
+    return "down";
+  }
+};

+ 2 - 3
apps/insights/src/components/PriceFeeds/epoch-select.module.scss → apps/insights/src/components/PriceFeeds/asset-classes-card.module.scss

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

+ 122 - 0
apps/insights/src/components/PriceFeeds/asset-classes-card.tsx

@@ -0,0 +1,122 @@
+"use client";
+
+import { Badge } from "@pythnetwork/component-library/Badge";
+import {
+  CLOSE_DURATION_IN_MS,
+  Drawer,
+  DrawerTrigger,
+} from "@pythnetwork/component-library/Drawer";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { Table } from "@pythnetwork/component-library/Table";
+import { usePathname } from "next/navigation";
+import { createSerializer } from "nuqs";
+import { Suspense, use, useMemo } from "react";
+import { useCollator } from "react-aria";
+
+import styles from "./asset-classes-card.module.scss";
+import { queryParams, useQuery } from "./use-query";
+
+type Props = {
+  numFeedsByAssetClassPromise: Promise<Record<string, number>>;
+};
+
+export const AssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => (
+  <Suspense
+    fallback={
+      <StatCard stat={<Skeleton width={10} />} {...sharedStatCardProps} />
+    }
+  >
+    <ResolvedAssetClassesCard
+      numFeedsByAssetClassPromise={numFeedsByAssetClassPromise}
+    />
+  </Suspense>
+);
+
+const ResolvedAssetClassesCard = ({ numFeedsByAssetClassPromise }: Props) => {
+  const numFeedsByAssetClass = use(numFeedsByAssetClassPromise);
+  const numAssetClasses = useMemo(
+    () => Object.keys(numFeedsByAssetClass).length,
+    [numFeedsByAssetClass],
+  );
+
+  return (
+    <DrawerTrigger>
+      <StatCard stat={numAssetClasses} {...sharedStatCardProps} />
+      <Drawer
+        title={
+          <div className={styles.drawerTitle}>
+            <span>Asset Classes</span>
+            <Badge>{numAssetClasses}</Badge>
+          </div>
+        }
+      >
+        {({ close }) => (
+          <AssetClassTable
+            numFeedsByAssetClass={numFeedsByAssetClass}
+            closeDrawer={close}
+          />
+        )}
+      </Drawer>
+    </DrawerTrigger>
+  );
+};
+
+const sharedStatCardProps = {
+  header: "Asset Classes",
+};
+
+type AssetClassTableProps = {
+  numFeedsByAssetClass: Record<string, number>;
+  closeDrawer: () => void;
+};
+
+const AssetClassTable = ({
+  numFeedsByAssetClass,
+  closeDrawer,
+}: AssetClassTableProps) => {
+  const collator = useCollator();
+  const pathname = usePathname();
+  const { updateAssetClass } = useQuery();
+  const assetClassRows = useMemo(
+    () =>
+      Object.entries(numFeedsByAssetClass)
+        .sort(([a], [b]) => collator.compare(a, b))
+        .map(([assetClass, count]) => {
+          const serialize = createSerializer(queryParams);
+          return {
+            id: assetClass,
+            href: `${pathname}${serialize({ assetClass })}`,
+            onAction: () => {
+              closeDrawer();
+              setTimeout(() => {
+                updateAssetClass(assetClass);
+              }, CLOSE_DURATION_IN_MS);
+            },
+            data: {
+              assetClass,
+              count: <Badge style="outline">{count}</Badge>,
+            },
+          };
+        }),
+    [numFeedsByAssetClass, collator, closeDrawer, pathname, updateAssetClass],
+  );
+  return (
+    <Table
+      fill
+      divide
+      label="Asset Classes"
+      columns={[
+        {
+          id: "assetClass",
+          name: "ASSET CLASS",
+          isRowHeader: true,
+          fill: true,
+          alignment: "left",
+        },
+        { id: "count", name: "COUNT", alignment: "center" },
+      ]}
+      rows={assetClassRows}
+    />
+  );
+};

+ 0 - 42
apps/insights/src/components/PriceFeeds/columns.ts

@@ -1,42 +0,0 @@
-import type { ColumnConfig } from "@pythnetwork/component-library/Table";
-
-export const columns = [
-  {
-    id: "asset",
-    name: "ASSET",
-    isRowHeader: true,
-    alignment: "left",
-    loadingSkeletonWidth: 28,
-  },
-  {
-    id: "assetType",
-    name: "ASSET TYPE",
-    fill: true,
-    alignment: "left",
-    loadingSkeletonWidth: 20,
-  },
-  {
-    id: "price",
-    name: "PRICE",
-    alignment: "right",
-    loadingSkeletonWidth: 20,
-  },
-  {
-    id: "uptime",
-    name: "UPTIME",
-    alignment: "center",
-    loadingSkeletonWidth: 6,
-  },
-  {
-    id: "deviation",
-    name: "DEVIATION",
-    alignment: "center",
-    loadingSkeletonWidth: 6,
-  },
-  {
-    id: "staleness",
-    name: "STALENESS",
-    alignment: "center",
-    loadingSkeletonWidth: 6,
-  },
-] satisfies ColumnConfig<string>[];

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

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

+ 152 - 0
apps/insights/src/components/PriceFeeds/coming-soon-show-all-button.tsx

@@ -0,0 +1,152 @@
+"use client";
+
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { Select } from "@pythnetwork/component-library/Select";
+import { Table } from "@pythnetwork/component-library/Table";
+import { type ReactNode, Suspense, useMemo, useState, use } from "react";
+import { useCollator, useFilter } from "react-aria";
+
+import styles from "./coming-soon-show-all-button.module.scss";
+
+type Props = {
+  comingSoonPromise: Promise<ComingSoonPriceFeed[]>;
+};
+
+type ComingSoonPriceFeed = {
+  symbol: string;
+  id: string;
+  displaySymbol: string;
+  assetClassAsString: string;
+  priceFeedName: ReactNode;
+  assetClass: ReactNode;
+};
+
+export const ComingSoonShowAllButton = ({ comingSoonPromise }: Props) => (
+  <Suspense fallback={<Button isPending {...sharedButtonProps} />}>
+    <ResolvedComingSoonShowAllButton comingSoonPromise={comingSoonPromise} />
+  </Suspense>
+);
+
+const ResolvedComingSoonShowAllButton = ({ comingSoonPromise }: Props) => {
+  const comingSoon = use(comingSoonPromise);
+
+  return (
+    <DrawerTrigger>
+      <Button {...sharedButtonProps} />
+      <Drawer
+        className={styles.comingSoonCard ?? ""}
+        title={
+          <div className={styles.drawerTitle}>
+            <span>Coming Soon</span>
+            <Badge>{comingSoon.length}</Badge>
+          </div>
+        }
+      >
+        <ComingSoonContents comingSoon={comingSoon} />
+      </Drawer>
+    </DrawerTrigger>
+  );
+};
+
+const sharedButtonProps = {
+  size: "xs" as const,
+  variant: "outline" as const,
+  children: "Show all",
+};
+
+type ComingSoonTableProps = {
+  comingSoon: ComingSoonPriceFeed[];
+};
+
+const ComingSoonContents = ({ comingSoon }: ComingSoonTableProps) => {
+  const [search, setSearch] = useState("");
+  const [assetClass, setAssetClass] = useState("");
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const assetClasses = useMemo(
+    () =>
+      [
+        ...new Set(comingSoon.map((priceFeed) => priceFeed.assetClassAsString)),
+      ].sort((a, b) => collator.compare(a, b)),
+    [comingSoon, collator],
+  );
+  const sortedFeeds = useMemo(
+    () =>
+      comingSoon.sort((a, b) =>
+        collator.compare(a.displaySymbol, b.displaySymbol),
+      ),
+    [collator, comingSoon],
+  );
+  const feedsFilteredByAssetClass = useMemo(
+    () =>
+      assetClass
+        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
+        : sortedFeeds,
+    [assetClass, sortedFeeds],
+  );
+  const filteredFeeds = useMemo(
+    () =>
+      search === ""
+        ? feedsFilteredByAssetClass
+        : feedsFilteredByAssetClass.filter((feed) =>
+            filter.contains(feed.symbol, search),
+          ),
+    [search, feedsFilteredByAssetClass, filter],
+  );
+  const rows = useMemo(
+    () =>
+      filteredFeeds.map(({ id, priceFeedName, assetClass }) => ({
+        id,
+        data: { priceFeedName, assetClass },
+      })),
+    [filteredFeeds],
+  );
+  return (
+    <>
+      <div className={styles.searchBar}>
+        <SearchInput
+          size="sm"
+          defaultValue={search}
+          onChange={setSearch}
+          width={40}
+        />
+        <Select
+          optionGroups={[
+            { name: "All", options: [""] },
+            { name: "Asset classes", options: assetClasses },
+          ]}
+          hideGroupLabel
+          show={(value) => (value === "" ? "All" : value)}
+          placement="bottom end"
+          selectedKey={assetClass}
+          onSelectionChange={setAssetClass}
+          label="Asset Class"
+          size="sm"
+          variant="outline"
+          hideLabel
+          buttonLabel={assetClass === "" ? "Asset Class" : assetClass}
+        />
+      </div>
+      <Table
+        fill
+        divide
+        label="Asset Classes"
+        className={styles.priceFeeds ?? ""}
+        columns={[
+          {
+            id: "priceFeedName",
+            name: "PRICE FEED",
+            isRowHeader: true,
+            fill: true,
+            alignment: "left",
+          },
+          { id: "assetClass", name: "ASSET CLASS", alignment: "right" },
+        ]}
+        rows={rows}
+      />
+    </>
+  );
+};

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

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

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

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

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

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

+ 53 - 0
apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss

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

+ 243 - 0
apps/insights/src/components/PriceFeeds/featured-recently-added.tsx

@@ -0,0 +1,243 @@
+"use client";
+
+import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { type ReactNode, Suspense, use, useMemo } from "react";
+import { useNumberFormatter } from "react-aria";
+import { z } from "zod";
+
+import styles from "./featured-recently-added.module.scss";
+import { StateType, useData } from "../../use-data";
+import { SKELETON_WIDTH, LivePrice, useLivePrice } from "../LivePrices";
+
+const ONE_SECOND_IN_MS = 1000;
+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 = {
+  placeholderPriceFeedName: ReactNode;
+  recentlyAddedPromise: Promise<RecentlyAddedPriceFeed[]>;
+};
+
+type RecentlyAddedPriceFeed = {
+  id: string;
+  symbol: string;
+  priceFeedName: ReactNode;
+};
+
+export const FeaturedRecentlyAdded = ({
+  placeholderPriceFeedName,
+  recentlyAddedPromise,
+}: Props) => (
+  <div className={styles.featuredRecentlyAdded}>
+    <Suspense
+      fallback={
+        <Placeholder placeholderPriceFeedName={placeholderPriceFeedName} />
+      }
+    >
+      <ResolvedFeaturedRecentlyAdded
+        recentlyAddedPromise={recentlyAddedPromise}
+      />
+    </Suspense>
+  </div>
+);
+
+type PlaceholderProps = {
+  placeholderPriceFeedName: ReactNode;
+};
+
+const Placeholder = ({ placeholderPriceFeedName }: PlaceholderProps) => (
+  <>
+    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
+    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
+    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
+    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
+    <PlaceholderCard placeholderPriceFeedName={placeholderPriceFeedName} />
+  </>
+);
+
+const PlaceholderCard = ({ placeholderPriceFeedName }: PlaceholderProps) => (
+  <Card
+    title={placeholderPriceFeedName}
+    footer={
+      <Footer
+        price={<Skeleton width={SKELETON_WIDTH} />}
+        changePercent={<Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />}
+      />
+    }
+    {...sharedCardProps}
+  />
+);
+
+type ResolvedFeaturedRecentlyAddedProps = {
+  recentlyAddedPromise: Promise<RecentlyAddedPriceFeed[]>;
+};
+
+const ResolvedFeaturedRecentlyAdded = ({
+  recentlyAddedPromise,
+}: ResolvedFeaturedRecentlyAddedProps) => {
+  const recentlyAdded = use(recentlyAddedPromise);
+  const feedKeys = useMemo(
+    () => recentlyAdded.map(({ id }) => id),
+    [recentlyAdded],
+  );
+  const symbols = useMemo(
+    () => recentlyAdded.map(({ symbol }) => symbol),
+    [recentlyAdded],
+  );
+  const state = useData(
+    ["yesterdaysPrices", feedKeys],
+    () => getYesterdaysPrices(symbols),
+    {
+      refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
+    },
+  );
+
+  return (
+    <>
+      {recentlyAdded.map(({ priceFeedName, id, symbol }, i) => (
+        <Card
+          key={i}
+          href="#"
+          title={priceFeedName}
+          footer={
+            <Footer
+              price={<LivePrice account={id} />}
+              changePercent={
+                <ChangePercent
+                  yesterdaysPriceState={state}
+                  feedKey={id}
+                  symbol={symbol}
+                />
+              }
+            />
+          }
+          {...sharedCardProps}
+        />
+      ))}
+    </>
+  );
+};
+
+type FooterProps = {
+  price: ReactNode;
+  changePercent: ReactNode;
+};
+
+const Footer = ({ price, changePercent }: FooterProps) => (
+  <div className={styles.footer}>
+    {price}
+    <div className={styles.changePercent}>{changePercent}</div>
+  </div>
+);
+
+const sharedCardProps = {
+  className: styles.recentlyAddedFeed,
+  variant: "tertiary" as const,
+};
+
+const getYesterdaysPrices = async (
+  symbols: string[],
+): Promise<Record<string, number>> => {
+  const url = new URL("/yesterdays-prices", window.location.origin);
+  for (const symbol of symbols) {
+    url.searchParams.append("symbols", symbol);
+  }
+  const response = await fetch(url);
+  const data: unknown = await response.json();
+  return yesterdaysPricesSchema.parse(data);
+};
+
+const yesterdaysPricesSchema = z.record(z.string(), z.number());
+
+type ChangePercentProps = {
+  yesterdaysPriceState: ReturnType<
+    typeof useData<Awaited<ReturnType<typeof getYesterdaysPrices>>>
+  >;
+  feedKey: string;
+  symbol: string;
+};
+
+const ChangePercent = ({
+  yesterdaysPriceState,
+  feedKey,
+  symbol,
+}: ChangePercentProps) => {
+  switch (yesterdaysPriceState.type) {
+    case StateType.Error: {
+      // eslint-disable-next-line unicorn/no-null
+      return null;
+    }
+
+    case StateType.Loading:
+    case StateType.NotLoaded: {
+      return <Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />;
+    }
+
+    case StateType.Loaded: {
+      const yesterdaysPrice = yesterdaysPriceState.data[symbol];
+      // eslint-disable-next-line unicorn/no-null
+      return yesterdaysPrice === undefined ? null : (
+        <ChangePercentLoaded priorPrice={yesterdaysPrice} feedKey={feedKey} />
+      );
+    }
+  }
+};
+
+type ChangePercentLoadedProps = {
+  priorPrice: number;
+  feedKey: string;
+};
+
+const ChangePercentLoaded = ({
+  priorPrice,
+  feedKey,
+}: ChangePercentLoadedProps) => {
+  const currentPrice = useLivePrice(feedKey);
+
+  return currentPrice === undefined ? (
+    <Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />
+  ) : (
+    <PriceDifference
+      currentPrice={currentPrice.price}
+      priorPrice={priorPrice}
+    />
+  );
+};
+
+type PriceDifferenceProps = {
+  currentPrice: number;
+  priorPrice: number;
+};
+
+const PriceDifference = ({
+  currentPrice,
+  priorPrice,
+}: PriceDifferenceProps) => {
+  const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 });
+  const direction = getDirection(currentPrice, priorPrice);
+
+  return (
+    <span data-direction={direction} className={styles.price}>
+      <CaretUp weight="fill" className={styles.caret} />
+      {numberFormatter.format(
+        (100 * Math.abs(currentPrice - priorPrice)) / currentPrice,
+      )}
+      %
+    </span>
+  );
+};
+
+const getDirection = (currentPrice: number, priorPrice: number) => {
+  if (currentPrice < priorPrice) {
+    return "down";
+  } else if (currentPrice > priorPrice) {
+    return "up";
+  } else {
+    return "flat";
+  }
+};

+ 55 - 16
apps/insights/src/components/PriceFeeds/index.module.scss

@@ -1,16 +1,41 @@
 @use "@pythnetwork/component-library/theme";
 
-.assetName {
+.priceFeeds {
+  @include theme.max-width;
+
+  .header {
+    @include theme.h3;
+
+    color: theme.color("heading");
+    font-weight: theme.font-weight("semibold");
+    margin: theme.spacing(6) 0;
+  }
+
+  .body {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(6);
+
+    .stats {
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(4);
+      align-items: center;
+
+      & > * {
+        flex: 1;
+      }
+    }
+  }
+}
+
+.priceFeedNameAndIcon,
+.priceFeedNameAndAssetClass {
   display: flex;
   flex-flow: row nowrap;
   gap: theme.spacing(3);
 
-  .icon {
-    width: theme.spacing(6);
-    height: theme.spacing(6);
-  }
-
-  .name {
+  .priceFeedName {
     display: flex;
     flex-flow: row nowrap;
     align-items: center;
@@ -31,13 +56,27 @@
   }
 }
 
-.assetType {
-  display: inline-block;
-  border-radius: theme.border-radius("3xl");
-  border: 1px solid theme.color("states", "neutral", "normal");
-  padding: 0 theme.spacing(2);
-  font-size: theme.font-size("xxs");
-  text-transform: uppercase;
-  line-height: theme.spacing(4);
-  color: theme.color("states", "neutral", "normal");
+.priceFeedNameAndIcon .priceFeedIcon {
+  width: theme.spacing(6);
+  height: theme.spacing(6);
+}
+
+.priceFeedNameAndAssetClass {
+  .priceFeedIcon {
+    width: theme.spacing(10);
+    height: theme.spacing(10);
+  }
+
+  .nameAndClass {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(1);
+
+    .assetClass {
+      font-size: theme.font-size("xs");
+      font-weight: theme.font-weight("medium");
+      line-height: theme.spacing(4);
+      color: theme.color("muted");
+    }
+  }
 }

+ 283 - 40
apps/insights/src/components/PriceFeeds/index.tsx

@@ -1,73 +1,303 @@
+import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown";
+import { StackPlus } from "@phosphor-icons/react/dist/ssr/StackPlus";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import base58 from "bs58";
 import Generic from "cryptocurrency-icons/svg/color/generic.svg";
 import { Fragment } from "react";
 import { z } from "zod";
 
+import { AssetClassesCard } from "./asset-classes-card";
+import { ComingSoonShowAllButton } from "./coming-soon-show-all-button";
+import { FeaturedComingSoon } from "./featured-coming-soon";
+import { FeaturedRecentlyAdded } from "./featured-recently-added";
 import styles from "./index.module.scss";
-import { Price } from "./prices";
-import { Results } from "./results";
+import { NumActiveFeeds } from "./num-active-feeds";
+import { PriceFeedsCard } from "./price-feeds-card";
 import { getIcon } from "../../icons";
 import { client } from "../../pyth";
+import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
+import { CopyButton } from "../CopyButton";
 
-export const PriceFeeds = async () => {
-  const priceFeeds = await getPriceFeeds();
+const PRICE_FEEDS_ANCHOR = "priceFeeds";
+
+export const PriceFeeds = () => {
+  const priceFeeds = getPriceFeeds();
 
   return (
-    <Results
-      priceFeeds={priceFeeds.map(({ symbol, product }) => ({
-        symbol,
-        key: product.price_account,
-        displaySymbol: product.display_symbol,
-        data: {
-          asset: <AssetName>{product.display_symbol}</AssetName>,
-          assetType: <AssetType>{product.asset_type}</AssetType>,
-          price: <Price account={product.price_account} />,
-          uptime: 43,
-          deviation: 56,
-          staleness: 46,
-        },
-      }))}
+    <div className={styles.priceFeeds}>
+      <h1 className={styles.header}>Price Feeds</h1>
+      <div className={styles.body}>
+        <div className={styles.stats}>
+          <StatCard
+            variant="primary"
+            header="Active Feeds"
+            stat={
+              <NumActiveFeeds
+                numFeedsPromise={priceFeeds.then(
+                  ({ activeFeeds }) => activeFeeds.length,
+                )}
+              />
+            }
+            href={`#${PRICE_FEEDS_ANCHOR}`}
+          />
+          <StatCard
+            header="Frequency"
+            stat={priceFeedsStaticConfig.updateFrequency}
+          />
+          <StatCard
+            header="Active Chains"
+            stat={priceFeedsStaticConfig.activeChains}
+            href="https://docs.pyth.network/price-feeds/contract-addresses"
+            target="_blank"
+          />
+          <AssetClassesCard
+            numFeedsByAssetClassPromise={priceFeeds.then(({ activeFeeds }) =>
+              getNumFeedsByAssetClass(activeFeeds),
+            )}
+          />
+        </div>
+        <Card title="Recently added" icon={<StackPlus />}>
+          <FeaturedRecentlyAdded
+            placeholderPriceFeedName={<PlaceholderPriceFeedNameAndAssetClass />}
+            recentlyAddedPromise={priceFeeds.then(({ activeFeeds }) =>
+              filterFeeds(
+                activeFeeds,
+                priceFeedsStaticConfig.featuredRecentlyAdded,
+              ).map(({ product, symbol }) => ({
+                id: product.price_account,
+                symbol,
+                priceFeedName: (
+                  <PriceFeedNameAndAssetClass
+                    assetClass={product.asset_type.toUpperCase()}
+                  >
+                    {product.display_symbol}
+                  </PriceFeedNameAndAssetClass>
+                ),
+              })),
+            )}
+          />
+        </Card>
+        <Card
+          title="Coming soon"
+          icon={<ClockCountdown />}
+          toolbar={
+            <ComingSoonShowAllButton
+              comingSoonPromise={priceFeeds.then(({ comingSoon }) =>
+                comingSoon.map(({ symbol, product }) => ({
+                  symbol,
+                  id: product.price_account,
+                  displaySymbol: product.display_symbol,
+                  assetClassAsString: product.asset_type,
+                  priceFeedName: (
+                    <PriceFeedNameAndIcon>
+                      {product.display_symbol}
+                    </PriceFeedNameAndIcon>
+                  ),
+                  assetClass: (
+                    <Badge variant="neutral" style="outline" size="xs">
+                      {product.asset_type.toUpperCase()}
+                    </Badge>
+                  ),
+                })),
+              )}
+            />
+          }
+        >
+          <FeaturedComingSoon
+            placeholderPriceFeedName={<PlaceholderPriceFeedNameAndAssetClass />}
+            comingSoonPromise={priceFeeds.then(({ comingSoon }) =>
+              [
+                ...filterFeeds(
+                  comingSoon,
+                  priceFeedsStaticConfig.featuredComingSoon,
+                ),
+                ...comingSoon.filter(
+                  ({ symbol }) =>
+                    !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
+                ),
+              ]
+                .slice(0, 5)
+                .map(({ product }) => ({
+                  priceFeedName: (
+                    <PriceFeedNameAndAssetClass
+                      assetClass={product.asset_type.toUpperCase()}
+                    >
+                      {product.display_symbol}
+                    </PriceFeedNameAndAssetClass>
+                  ),
+                })),
+            )}
+          />
+        </Card>
+        <PriceFeedsCard
+          id={PRICE_FEEDS_ANCHOR}
+          placeholderPriceFeedName={<PlaceholderPriceFeedNameAndIcon />}
+          priceFeedsPromise={priceFeeds.then(({ activeFeeds }) =>
+            activeFeeds.map(({ symbol, product, price }) => ({
+              symbol,
+              id: product.price_account,
+              displaySymbol: product.display_symbol,
+              assetClassAsString: product.asset_type,
+              exponent: price.exponent,
+              numPublishers: price.numQuoters,
+              weeklySchedule: product.weekly_schedule,
+              priceFeedName: (
+                <PriceFeedNameAndIcon>
+                  {product.display_symbol}
+                </PriceFeedNameAndIcon>
+              ),
+              assetClass: (
+                <Badge variant="neutral" style="outline" size="xs">
+                  {product.asset_type.toUpperCase()}
+                </Badge>
+              ),
+              priceFeedId: (
+                <CopyButton text={toHex(product.price_account)}>
+                  {toTruncatedHex(product.price_account)}
+                </CopyButton>
+              ),
+            })),
+          )}
+        />
+      </div>
+    </div>
+  );
+};
+
+const PriceFeedNameAndAssetClass = ({
+  children,
+  assetClass,
+}: {
+  children: string;
+  assetClass: string;
+}) => (
+  <div className={styles.priceFeedNameAndAssetClass}>
+    <PriceFeedIcon>{children}</PriceFeedIcon>
+    <div className={styles.nameAndClass}>
+      <PriceFeedName>{children}</PriceFeedName>
+      <div className={styles.assetClass}>{assetClass}</div>
+    </div>
+  </div>
+);
+
+const PlaceholderPriceFeedNameAndAssetClass = () => (
+  <div className={styles.priceFeedNameAndAssetClass}>
+    <div className={styles.priceFeedIcon}>
+      <Skeleton round />
+    </div>
+    <div className={styles.nameAndClass}>
+      <div className={styles.priceFeedName}>
+        <Skeleton width={20} />
+      </div>
+      <div className={styles.assetClass}>
+        <Skeleton width={10} />
+      </div>
+    </div>
+  </div>
+);
+
+const PriceFeedNameAndIcon = ({ children }: { children: string }) => (
+  <div className={styles.priceFeedNameAndIcon}>
+    <PriceFeedIcon>{children}</PriceFeedIcon>
+    <PriceFeedName>{children}</PriceFeedName>
+  </div>
+);
+
+const PlaceholderPriceFeedNameAndIcon = () => (
+  <div className={styles.priceFeedNameAndIcon}>
+    <div className={styles.priceFeedIcon}>
+      <Skeleton round />
+    </div>
+    <div className={styles.priceFeedName}>
+      <Skeleton width={20} />
+    </div>
+  </div>
+);
+
+const PriceFeedIcon = ({ children }: { children: string }) => {
+  const firstPart = children.split("/")[0];
+  const Icon = firstPart ? (getIcon(firstPart) ?? Generic) : Generic;
+
+  return (
+    <Icon
+      className={styles.priceFeedIcon}
+      width="100%"
+      height="100%"
+      viewBox="0 0 32 32"
     />
   );
 };
 
-const AssetName = ({ children }: { children: string }) => {
+const PriceFeedName = ({ children }: { children: string }) => {
   const [firstPart, ...parts] = children.split("/");
-  const Icon = firstPart ? (getIcon(firstPart) ?? Generic) : Generic;
+
   return (
-    <div className={styles.assetName}>
-      <Icon
-        className={styles.icon}
-        width="100%"
-        height="100%"
-        viewBox="0 0 32 32"
-      />
-      <div className={styles.name}>
-        <span className={styles.firstPart}>{firstPart}</span>
-        {parts.map((part, i) => (
-          <Fragment key={i}>
-            <span className={styles.divider}>/</span>
-            <span className={styles.part}>{part}</span>
-          </Fragment>
-        ))}
-      </div>
+    <div className={styles.priceFeedName}>
+      <span className={styles.firstPart}>{firstPart}</span>
+      {parts.map((part, i) => (
+        <Fragment key={i}>
+          <span className={styles.divider}>/</span>
+          <span className={styles.part}>{part}</span>
+        </Fragment>
+      ))}
     </div>
   );
 };
 
-const AssetType = ({ children }: { children: string }) => (
-  <span className={styles.assetType}>{children}</span>
-);
+const toHex = (value: string) => toHexString(base58.decode(value));
+
+const toTruncatedHex = (value: string) => {
+  const hex = toHex(value);
+  return `${hex.slice(0, 6)}...${hex.slice(-4)}`;
+};
+
+const toHexString = (byteArray: Uint8Array) =>
+  `0x${Array.from(byteArray, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
 
 const getPriceFeeds = async () => {
   const data = await client.getData();
-  return priceFeedsSchema.parse(
+  const priceFeeds = priceFeedsSchema.parse(
     data.symbols.map((symbol) => ({
       symbol,
       product: data.productFromSymbol.get(symbol),
+      price: data.productPrice.get(symbol),
     })),
   );
+  const activeFeeds = priceFeeds.filter((feed) => isActive(feed));
+  const comingSoon = priceFeeds.filter((feed) => !isActive(feed));
+  return { activeFeeds, comingSoon };
+};
+
+const getNumFeedsByAssetClass = (
+  feeds: { product: { asset_type: string } }[],
+): Record<string, number> => {
+  const classes: Record<string, number> = {};
+  for (const feed of feeds) {
+    const assetType = feed.product.asset_type;
+    classes[assetType] = (classes[assetType] ?? 0) + 1;
+  }
+  return classes;
 };
 
+const filterFeeds = <T extends { symbol: string }>(
+  feeds: T[],
+  symbols: string[],
+): T[] =>
+  symbols.map((symbol) => {
+    const feed = feeds.find((feed) => feed.symbol === symbol);
+    if (feed) {
+      return feed;
+    } else {
+      throw new NoSuchFeedError(symbol);
+    }
+  });
+
+const isActive = (feed: { price: { minPublishers: number } }) =>
+  feed.price.minPublishers <= 50;
+
 const priceFeedsSchema = z.array(
   z.object({
     symbol: z.string(),
@@ -75,6 +305,19 @@ const priceFeedsSchema = z.array(
       display_symbol: z.string(),
       asset_type: z.string(),
       price_account: z.string(),
+      weekly_schedule: z.string().optional(),
+    }),
+    price: z.object({
+      exponent: z.number(),
+      numQuoters: z.number(),
+      minPublishers: z.number(),
     }),
   }),
 );
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

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

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

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

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

+ 0 - 14
apps/insights/src/components/PriceFeeds/loading.tsx

@@ -1,14 +0,0 @@
-import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
-import { TableCard } from "@pythnetwork/component-library/TableCard";
-
-import { columns } from "./columns";
-
-export const PriceFeedsLoading = () => (
-  <TableCard
-    label="Price Feeds"
-    icon={ChartLine}
-    columns={columns}
-    isLoading={true}
-    rows={[]}
-  />
-);

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

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

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

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

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

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

+ 0 - 13
apps/insights/src/components/PriceFeeds/prices.module.scss

@@ -1,13 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.price {
-  transition: color 100ms linear;
-
-  &[data-direction="up"] {
-    color: theme.color("states", "success", "normal");
-  }
-
-  &[data-direction="down"] {
-    color: theme.color("states", "error", "normal");
-  }
-}

+ 0 - 123
apps/insights/src/components/PriceFeeds/prices.tsx

@@ -1,123 +0,0 @@
-"use client";
-
-import { useLogger } from "@pythnetwork/app-logger";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { useMap } from "@react-hookz/web";
-import { PublicKey } from "@solana/web3.js";
-import {
-  type ComponentProps,
-  createContext,
-  useContext,
-  useEffect,
-} from "react";
-import { useNumberFormatter } from "react-aria";
-
-import styles from "./prices.module.scss";
-import { client, subscribe } from "../../pyth";
-
-const PriceContext = createContext<
-  Map<string, [number, ChangeDirection]> | undefined
->(undefined);
-
-type ChangeDirection = "up" | "down" | "flat";
-
-type PriceProviderProps = Omit<ComponentProps<typeof PriceContext>, "value"> & {
-  feedKeys: string[];
-};
-
-export const PriceProvider = ({ feedKeys, ...props }: PriceProviderProps) => {
-  const priceData = usePriceData(feedKeys);
-
-  return <PriceContext value={priceData} {...props} />;
-};
-
-export const Price = ({ account }: { account: string }) => {
-  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = usePrices().get(account);
-
-  return price === undefined ? (
-    <Skeleton width={20} />
-  ) : (
-    <span className={styles.price} data-direction={price[1]}>
-      {numberFormatter.format(price[0])}
-    </span>
-  );
-};
-
-const usePriceData = (feedKeys: string[]) => {
-  const priceData = useMap<string, [number, ChangeDirection]>([]);
-  const logger = useLogger();
-
-  useEffect(() => {
-    const initialFeedKeys = feedKeys.filter((key) => !priceData.has(key));
-    if (initialFeedKeys.length > 0) {
-      client
-        .getAssetPricesFromAccounts(
-          initialFeedKeys.map((key) => new PublicKey(key)),
-        )
-        .then((initialPrices) => {
-          for (const [i, price] of initialPrices.entries()) {
-            const key = initialFeedKeys[i];
-            if (key && !priceData.has(key)) {
-              priceData.set(key, [price.aggregate.price, "flat"]);
-            }
-          }
-        })
-        .catch((error: unknown) => {
-          logger.error("Failed to fetch initial prices", error);
-        });
-    }
-
-    const connection = subscribe(
-      feedKeys.map((key) => new PublicKey(key)),
-      ({ price_account }, { aggregate }) => {
-        if (price_account) {
-          const prevPrice = priceData.get(price_account)?.[0];
-          priceData.set(price_account, [
-            aggregate.price,
-            getChangeDirection(prevPrice, aggregate.price),
-          ]);
-        }
-      },
-    );
-
-    connection.start().catch((error: unknown) => {
-      logger.error("Failed to subscribe to prices", error);
-    });
-    return () => {
-      connection.stop().catch((error: unknown) => {
-        logger.error("Failed to unsubscribe from price updates", error);
-      });
-    };
-  }, [feedKeys, logger, priceData]);
-
-  return new Map(priceData);
-};
-
-const usePrices = () => {
-  const prices = useContext(PriceContext);
-  if (prices === undefined) {
-    throw new NotInitializedError();
-  }
-  return prices;
-};
-
-class NotInitializedError extends Error {
-  constructor() {
-    super("This component must be a child of <PriceProvider>");
-    this.name = "NotInitializedError";
-  }
-}
-
-const getChangeDirection = (
-  prevPrice: number | undefined,
-  price: number,
-): ChangeDirection => {
-  if (prevPrice === undefined || prevPrice === price) {
-    return "flat";
-  } else if (prevPrice < price) {
-    return "up";
-  } else {
-    return "down";
-  }
-};

+ 0 - 153
apps/insights/src/components/PriceFeeds/results.tsx

@@ -1,153 +0,0 @@
-"use client";
-
-import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
-import { useLogger } from "@pythnetwork/app-logger";
-import { Paginator } from "@pythnetwork/component-library/Paginator";
-import { SearchInput } from "@pythnetwork/component-library/SearchInput";
-import { TableCard } from "@pythnetwork/component-library/TableCard";
-import { usePathname } from "next/navigation";
-import {
-  parseAsString,
-  parseAsInteger,
-  useQueryStates,
-  createSerializer,
-} from "nuqs";
-import {
-  type ComponentProps,
-  useTransition,
-  useMemo,
-  useCallback,
-} from "react";
-import { useFilter, useCollator } from "react-aria";
-
-import { columns } from "./columns";
-import { PriceProvider } from "./prices";
-
-type Props = {
-  priceFeeds: {
-    symbol: string;
-    key: string;
-    displaySymbol: string;
-    data: ComponentProps<
-      typeof TableCard<(typeof columns)[number]["id"]>
-    >["rows"][number]["data"];
-  }[];
-};
-
-const params = {
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(20),
-  search: parseAsString.withDefault(""),
-};
-
-export const Results = ({ priceFeeds }: Props) => {
-  const [isTransitioning, startTransition] = useTransition();
-  const [{ page, pageSize, search }, setQuery] = useQueryStates(params);
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const collator = useCollator();
-  const filteredFeeds = useMemo(
-    () =>
-      search === ""
-        ? priceFeeds
-        : priceFeeds.filter((feed) => filter.contains(feed.symbol, search)),
-    [search, priceFeeds, filter],
-  );
-  const sortedRows = useMemo(
-    () =>
-      filteredFeeds.sort((a, b) =>
-        collator.compare(a.displaySymbol, b.displaySymbol),
-      ),
-    [filteredFeeds, collator],
-  );
-  const paginatedRows = useMemo(
-    () => sortedRows.slice((page - 1) * pageSize, page * pageSize),
-    [page, pageSize, sortedRows],
-  );
-  const rows = useMemo(
-    () => paginatedRows.map(({ key, data }) => ({ id: key, href: "/", data })),
-    [paginatedRows],
-  );
-  const numPages = useMemo(
-    () => Math.ceil(filteredFeeds.length / pageSize),
-    [filteredFeeds, pageSize],
-  );
-
-  const logger = useLogger();
-
-  const updateQuery = useCallback(
-    (...params: Parameters<typeof setQuery>) => {
-      window.scrollTo({ top: 0, behavior: "smooth" });
-      startTransition(() => {
-        setQuery(...params).catch((error: unknown) => {
-          logger.error("Failed to update query", error);
-        });
-      });
-    },
-    [setQuery, startTransition, logger],
-  );
-
-  const updatePage = useCallback(
-    (newPage: number) => {
-      updateQuery({ page: newPage });
-    },
-    [updateQuery],
-  );
-
-  const updatePageSize = useCallback(
-    (newPageSize: number) => {
-      updateQuery({ page: 1, pageSize: newPageSize });
-    },
-    [updateQuery],
-  );
-
-  const updateSearch = useCallback(
-    (newSearch: string) => {
-      updateQuery({ page: 1, search: newSearch });
-    },
-    [updateQuery],
-  );
-
-  const feedKeys = useMemo(() => rows.map((row) => row.id), [rows]);
-
-  const pathname = usePathname();
-
-  const mkPageLink = useCallback(
-    (page: number) => {
-      const serialize = createSerializer(params);
-      return `${pathname}${serialize({ page, pageSize })}`;
-    },
-    [pathname, pageSize],
-  );
-
-  return (
-    <PriceProvider feedKeys={feedKeys}>
-      <TableCard
-        label="Price Feeds"
-        icon={ChartLine}
-        columns={columns}
-        isUpdating={isTransitioning}
-        rows={rows}
-        renderEmptyState={() => <p>No results!</p>}
-        toolbar={
-          <SearchInput
-            defaultValue={search}
-            onChange={updateSearch}
-            size="sm"
-            width={40}
-          />
-        }
-        footer={
-          <Paginator
-            numPages={numPages}
-            currentPage={page}
-            onPageChange={updatePage}
-            pageSize={pageSize}
-            onPageSizeChange={updatePageSize}
-            pageSizeOptions={[10, 20, 30, 40, 50]}
-            mkPageLink={mkPageLink}
-          />
-        }
-      />
-    </PriceProvider>
-  );
-};

+ 65 - 0
apps/insights/src/components/PriceFeeds/use-query.ts

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

+ 5 - 2
apps/insights/src/components/Publishers/loading.tsx

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

+ 14 - 16
apps/insights/src/components/Publishers/results.tsx

@@ -1,16 +1,12 @@
 "use client";
 
 import { useLogger } from "@pythnetwork/app-logger";
+import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
-import { TableCard } from "@pythnetwork/component-library/TableCard";
+import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
 import { usePathname } from "next/navigation";
 import { parseAsInteger, useQueryStates, createSerializer } from "nuqs";
-import {
-  type ComponentProps,
-  useTransition,
-  useMemo,
-  useCallback,
-} from "react";
+import { useTransition, useMemo, useCallback } from "react";
 
 import { columns } from "./columns";
 
@@ -18,9 +14,7 @@ type Props = {
   publishers: {
     key: string;
     rank: number;
-    data: ComponentProps<
-      typeof TableCard<(typeof columns)[number]["id"]>
-    >["rows"][number]["data"];
+    data: RowConfig<(typeof columns)[number]["id"]>["data"];
   }[];
 };
 
@@ -84,11 +78,8 @@ export const Results = ({ publishers }: Props) => {
   );
 
   return (
-    <TableCard
-      label="Publishers"
-      columns={columns}
-      isUpdating={isTransitioning}
-      rows={rows}
+    <Card
+      title="Publishers"
       footer={
         <Paginator
           numPages={numPages}
@@ -100,6 +91,13 @@ export const Results = ({ publishers }: Props) => {
           pageSizeOptions={[10, 20, 30, 40, 50]}
         />
       }
-    />
+    >
+      <Table
+        label="Publishers"
+        columns={columns}
+        isUpdating={isTransitioning}
+        rows={rows}
+      />
+    </Card>
   );
 };

+ 5 - 4
apps/insights/src/components/Root/footer.module.scss

@@ -38,12 +38,13 @@
       // gap-8
 
       .logoLink {
-        margin: -#{theme.spacing(2)};
-        border-radius: theme.border-radius();
-        padding: theme.spacing(2);
+        height: theme.spacing(5);
+        box-sizing: content-box;
+        padding: theme.spacing(3);
+        margin: -#{theme.spacing(3)};
 
         .logo {
-          height: theme.spacing(5);
+          height: 100%;
         }
 
         .logoLabel {

+ 39 - 9
apps/insights/src/components/Root/header.module.scss

@@ -3,9 +3,9 @@
 .header {
   position: sticky;
   top: 0;
-  height: theme.spacing(20);
   width: 100%;
-  background-color: theme.color("background", "primary");
+  background-color: theme.color("background", "nav-blur");
+  backdrop-filter: blur(32px);
 
   .content {
     height: 100%;
@@ -20,14 +20,26 @@
 
       @include theme.row;
 
-      .logo {
-        margin-top: 0.5646rem;
-        height: 2.8146rem;
-        width: theme.spacing(9);
-      }
+      .logoLink {
+        padding: theme.spacing(3);
+        margin: -#{theme.spacing(3)};
+
+        .logoWrapper {
+          width: theme.spacing(9);
+          height: theme.spacing(9);
+          position: relative;
+
+          .logo {
+            position: absolute;
+            top: 0;
+            left: 0;
+            width: 100%;
+          }
+        }
 
-      .logoLabel {
-        @include theme.sr-only;
+        .logoLabel {
+          @include theme.sr-only;
+        }
       }
 
       .appName {
@@ -49,5 +61,23 @@
         margin-left: theme.spacing(1);
       }
     }
+
+    @media screen and (min-width: theme.$max-width + (2 * (theme.spacing(9) + theme.spacing(8) + theme.spacing(7)))) {
+      .leftMenu {
+        margin-left: -#{theme.spacing(9) + theme.spacing(7)};
+
+        .logoLink {
+          margin-right: -#{theme.spacing(2)};
+        }
+      }
+
+      .rightMenu {
+        margin-right: -#{theme.spacing(9) + theme.spacing(7)};
+
+        .themeSwitch {
+          margin-left: theme.spacing(5);
+        }
+      }
+    }
   }
 }

+ 4 - 2
apps/insights/src/components/Root/header.tsx

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

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

@@ -1,17 +1,22 @@
 @use "@pythnetwork/component-library/theme";
 
-.tabRoot {
-  display: grid;
-  min-height: 100dvh;
-  grid-template-rows: auto 1fr auto;
+$header-height: theme.spacing(20);
 
-  .main {
-    padding-top: theme.spacing(6);
-    padding-bottom: theme.spacing(12);
-    isolation: isolate;
-  }
+.root {
+  scroll-padding-top: $header-height;
+
+  .tabRoot {
+    display: grid;
+    min-height: 100dvh;
+    grid-template-rows: auto 1fr auto;
+
+    .main {
+      isolation: isolate;
+    }
 
-  .header {
-    z-index: 1;
+    .header {
+      z-index: 1;
+      height: $header-height;
+    }
   }
 }

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

@@ -12,6 +12,7 @@ import {
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
 } from "../../config/server";
+import { LivePricesProvider } from "../LivePrices";
 
 type Props = {
   children: ReactNode;
@@ -22,7 +23,8 @@ export const Root = ({ children }: Props) => (
     amplitudeApiKey={AMPLITUDE_API_KEY}
     googleAnalyticsId={GOOGLE_ANALYTICS_ID}
     enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
-    providers={[NuqsAdapter]}
+    providers={[NuqsAdapter, LivePricesProvider]}
+    className={styles.root}
   >
     <TabRoot className={styles.tabRoot ?? ""}>
       <Header className={styles.header} />

+ 12 - 0
apps/insights/src/static-data/price-feeds.tsx

@@ -0,0 +1,12 @@
+export const priceFeeds = {
+  activeChains: "80+",
+  updateFrequency: "400ms",
+  featuredRecentlyAdded: [
+    "Crypto.PYTH/USD",
+    "FX.EUR/USD",
+    "Equity.US.NFLX/USD",
+    "Commodities.WTI1M",
+    "Crypto.1INCH/USD",
+  ],
+  featuredComingSoon: ["Rates.US2Y", "Crypto.SOL/ETH", "Commodities.BRENT2M"],
+};

+ 57 - 0
apps/insights/src/use-data.ts

@@ -0,0 +1,57 @@
+import { useLogger } from "@pythnetwork/app-logger";
+import { useCallback } from "react";
+import useSWR, { type KeyedMutator } from "swr";
+
+export const useData = <T>(...args: Parameters<typeof useSWR<T>>) => {
+  const { data, isLoading, mutate, ...rest } = useSWR(...args);
+
+  const error = rest.error as unknown;
+  const logger = useLogger();
+
+  const reset = useCallback(() => {
+    mutate(undefined).catch(() => {
+      /* no-op */
+    });
+  }, [mutate]);
+
+  if (error) {
+    logger.error(error);
+    return State.ErrorState(new UseDataError(error), reset);
+  } else if (isLoading) {
+    return State.Loading();
+  } else if (data) {
+    return State.Loaded(data, mutate);
+  } else {
+    return State.NotLoaded();
+  }
+};
+
+export enum StateType {
+  NotLoaded,
+  Loading,
+  Loaded,
+  Error,
+}
+
+const State = {
+  NotLoaded: () => ({ type: StateType.NotLoaded as const }),
+  Loading: () => ({ type: StateType.Loading as const }),
+  Loaded: <T>(data: T, mutate: KeyedMutator<T>) => ({
+    type: StateType.Loaded as const,
+    mutate,
+    data,
+  }),
+  ErrorState: (error: UseDataError, reset: () => void) => ({
+    type: StateType.Error as const,
+    error,
+    reset,
+  }),
+};
+
+class UseDataError extends Error {
+  constructor(cause: unknown) {
+    super(cause instanceof Error ? cause.message : "");
+    this.name = "UseDataError";
+    this.cause = cause;
+  }
+}

+ 1 - 0
flake.nix

@@ -59,6 +59,7 @@
             pkgs.python3
             pkgs.python3Packages.distutils
             pkgs.graphviz
+            pkgs.anchor
           ];
         };
       }

+ 8 - 2
packages/component-library/.storybook/preview.tsx

@@ -1,5 +1,7 @@
+import { sans } from "@pythnetwork/fonts";
 import { withThemeByClassName } from "@storybook/addon-themes";
 import type { Preview, Decorator } from "@storybook/react";
+import clsx from "clsx";
 
 import "../src/Html/base.scss";
 import styles from "./storybook.module.scss";
@@ -7,6 +9,10 @@ import styles from "./storybook.module.scss";
 const preview = {
   parameters: {
     backgrounds: {
+      grid: {
+        cellSize: 4,
+        cellAmount: 4,
+      },
       options: [
         { name: "Primary", value: "var(--primary-background)" },
         { name: "Secondary", value: "var(--secondary-background)" },
@@ -24,8 +30,8 @@ export default preview;
 export const decorators: Decorator[] = [
   withThemeByClassName({
     themes: {
-      Light: styles.light ?? "",
-      Dark: styles.dark ?? "",
+      Light: clsx(sans.className, styles.light),
+      Dark: clsx(sans.className, styles.dark),
     },
     defaultTheme: "Light",
   }),

+ 1 - 0
packages/component-library/.storybook/storybook.module.scss

@@ -3,6 +3,7 @@
 html,
 body {
   height: 100%;
+  width: 100%;
 }
 
 :root {

+ 10 - 6
packages/component-library/src/AppTabs/index.module.scss

@@ -13,11 +13,11 @@
       position: absolute;
       inset: 0;
       border-radius: theme.border-radius("full");
-      background-color: white;
-      mix-blend-mode: difference;
-      outline: none;
-      z-index: 1;
-      transition: box-shadow 200ms linear;
+      background-color: theme.color("button", "solid", "background", "normal");
+      outline: 4px solid transparent;
+      outline-offset: 0;
+      z-index: -1;
+      transition: outline-color 200ms linear;
     }
 
     &[data-focus-visible] {
@@ -25,8 +25,12 @@
       border-color: transparent;
 
       .bubble {
-        box-shadow: 0 0 0 4px rgba(white, 0.4);
+        outline-color: theme.color("focus-dim");
       }
     }
+
+    &[data-selected] {
+      color: theme.color("button", "solid", "foreground");
+    }
   }
 }

+ 53 - 0
packages/component-library/src/Badge/index.module.scss

@@ -0,0 +1,53 @@
+@use "../theme";
+
+.badge {
+  display: inline flow-root;
+  border-radius: theme.border-radius("3xl");
+  transition-property: color, background-color, border-color;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  border: 1px solid var(--badge-color);
+
+  &[data-size="xs"] {
+    line-height: theme.spacing(4);
+    height: theme.spacing(4);
+    padding: 0 theme.spacing(2);
+    font-size: theme.font-size("xxs");
+    font-weight: theme.font-weight("medium");
+  }
+
+  &[data-size="md"] {
+    line-height: theme.spacing(6);
+    height: theme.spacing(6);
+    padding: 0 theme.spacing(3);
+    font-size: theme.font-size("xs");
+    font-weight: theme.font-weight("medium");
+  }
+
+  &[data-size="lg"] {
+    line-height: theme.spacing(9);
+    height: theme.spacing(9);
+    padding: 0 theme.spacing(5);
+    font-size: theme.font-size("sm");
+    font-weight: theme.font-weight("semibold");
+  }
+
+  @each $variant in ("neutral", "info", "warning", "error", "data", "success") {
+    &[data-variant="#{$variant}"] {
+      --badge-color: #{theme.color("states", $variant, "normal")};
+    }
+  }
+
+  &[data-variant="muted"] {
+    --badge-color: #{theme.color("muted")};
+  }
+
+  &[data-style="filled"] {
+    color: theme.color("background", "primary");
+    background: var(--badge-color);
+  }
+
+  &[data-style="outline"] {
+    color: var(--badge-color);
+  }
+}

+ 46 - 0
packages/component-library/src/Badge/index.stories.tsx

@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Badge as BadgeComponent, VARIANTS, SIZES, STYLES } from "./index.js";
+
+const meta = {
+  component: BadgeComponent,
+  argTypes: {
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    variant: {
+      control: "inline-radio",
+      options: VARIANTS,
+      table: {
+        category: "Variant",
+      },
+    },
+    style: {
+      control: "inline-radio",
+      options: STYLES,
+      table: {
+        category: "Variant",
+      },
+    },
+    size: {
+      control: "inline-radio",
+      options: SIZES,
+      table: {
+        category: "Variant",
+      },
+    },
+  },
+} satisfies Meta<typeof BadgeComponent>;
+export default meta;
+
+export const Badge = {
+  args: {
+    children: "A BADGE",
+    variant: "neutral",
+    style: "filled",
+    size: "md",
+  },
+} satisfies StoryObj<typeof BadgeComponent>;

+ 41 - 0
packages/component-library/src/Badge/index.tsx

@@ -0,0 +1,41 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+
+export const VARIANTS = [
+  "neutral",
+  "info",
+  "warning",
+  "error",
+  "data",
+  "success",
+  "muted",
+] as const;
+export const STYLES = ["filled", "outline"] as const;
+export const SIZES = ["xs", "md", "lg"] as const;
+
+type Props = ComponentProps<"span"> & {
+  variant?: (typeof VARIANTS)[number] | undefined;
+  style?: (typeof STYLES)[number] | undefined;
+  size?: (typeof SIZES)[number] | undefined;
+};
+
+export const Badge = ({
+  className,
+  variant = "neutral",
+  size = "md",
+  style = "filled",
+  children,
+  ...props
+}: Props) => (
+  <span
+    className={clsx(styles.badge, className)}
+    data-variant={variant}
+    data-size={size}
+    data-style={style}
+    {...props}
+  >
+    {children}
+  </span>
+);

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

@@ -34,7 +34,7 @@
 
       .text {
         padding: 0 theme.map-get-strict($values, "gap");
-        line-height: calc(theme.map-get-strict($values, "height") - 2px);
+        line-height: theme.map-get-strict($values, "height");
       }
     }
   }

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

@@ -1,9 +1,102 @@
 @use "../theme";
 
 .card {
+  display: block;
   flex-direction: column;
   gap: theme.spacing(2);
-  background-color: theme.color("background", "secondary");
   border-radius: theme.border-radius("2xl");
+  text-decoration: none;
+  color: theme.color("foreground");
+  outline-offset: 0;
+  outline: theme.spacing(1) solid transparent;
+  transition-property: outline-color, border-color, box-shadow, background;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  border: 1px solid transparent;
+  position: relative;
   padding: theme.spacing(1);
+  isolation: isolate;
+
+  @at-root button#{&} {
+    cursor: pointer;
+    width: 100%;
+  }
+
+  .cardHoverBackground {
+    border-radius: theme.border-radius("2xl");
+    position: absolute;
+    inset: -1px;
+    opacity: 0;
+    transition: opacity 100ms linear;
+    background: theme.color("button", "outline", "background", "hover");
+    z-index: -1;
+  }
+
+  .header {
+    padding: theme.spacing(3) theme.spacing(4);
+    position: relative;
+
+    .title {
+      margin: 0;
+      font-size: theme.font-size("base");
+      font-weight: theme.font-weight("medium");
+      color: theme.color("heading");
+      display: inline-flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(3);
+      align-items: center;
+
+      .icon {
+        font-size: theme.spacing(6);
+        height: theme.spacing(6);
+        color: theme.color("button", "primary", "background", "normal");
+      }
+    }
+
+    .toolbar {
+      position: absolute;
+      right: theme.spacing(3);
+      top: 0;
+      bottom: 0;
+      display: grid;
+      place-content: center;
+    }
+  }
+
+  .footer {
+    padding: theme.spacing(2);
+  }
+
+  &[data-variant="primary"] {
+    background: theme.color("background", "card-highlight");
+
+    &[data-hovered] {
+      @include theme.elevation("primary", 2);
+    }
+  }
+
+  &[data-variant="secondary"] {
+    background: theme.color("background", "secondary");
+
+    &[data-hovered] {
+      @include theme.elevation("default", 2);
+    }
+  }
+
+  &[data-variant="tertiary"] {
+    background: theme.color("background", "primary");
+
+    &[data-hovered] {
+      @include theme.elevation("default", 2);
+    }
+  }
+
+  &[data-hovered] .cardHoverBackground {
+    opacity: 1;
+  }
+
+  &[data-focus-visible] {
+    border-color: theme.color("focus");
+    outline-color: theme.color("focus-dim");
+  }
 }

+ 53 - 6
packages/component-library/src/Card/index.stories.tsx

@@ -1,21 +1,64 @@
+import * as Icon from "@phosphor-icons/react/dist/ssr";
 import type { Meta, StoryObj } from "@storybook/react";
 
-import { Card as CardComponent } from "./index.js";
+import { Card as CardComponent, VARIANTS } from "./index.js";
 
 const meta = {
   component: CardComponent,
-  parameters: {
-    backgrounds: {
-      disable: true,
-    },
-  },
   argTypes: {
+    href: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
+    target: {
+      control: "text",
+      table: {
+        category: "Link",
+      },
+    },
     children: {
       control: "text",
       table: {
         category: "Contents",
       },
     },
+    variant: {
+      control: "inline-radio",
+      options: VARIANTS,
+      table: {
+        category: "Variant",
+      },
+    },
+    title: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    icon: {
+      control: "select",
+      options: Object.keys(Icon),
+      mapping: Object.fromEntries(
+        Object.entries(Icon).map(([key, Icon]) => [key, <Icon key={key} />]),
+      ),
+      table: {
+        category: "Contents",
+      },
+    },
+    toolbar: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    footer: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
   },
 } satisfies Meta<typeof CardComponent>;
 export default meta;
@@ -23,5 +66,9 @@ export default meta;
 export const Card = {
   args: {
     children: "This is a card!",
+    variant: "secondary",
+    title: "",
+    toolbar: "",
+    footer: "",
   },
 } satisfies StoryObj<typeof CardComponent>;

+ 73 - 5
packages/component-library/src/Card/index.tsx

@@ -1,10 +1,78 @@
+"use client";
+
 import clsx from "clsx";
-import type { ComponentProps } from "react";
+import {
+  type ComponentProps,
+  type ElementType,
+  type ReactNode,
+  use,
+} from "react";
+import { OverlayTriggerStateContext } from "react-aria-components";
 
 import styles from "./index.module.scss";
+import { UnstyledButton } from "../UnstyledButton/index.js";
+import { UnstyledLink } from "../UnstyledLink/index.js";
+
+export const VARIANTS = ["primary", "secondary", "tertiary"] as const;
+
+type OwnProps = {
+  variant?: (typeof VARIANTS)[number] | undefined;
+  icon?: ReactNode | undefined;
+  title?: ReactNode | undefined;
+  toolbar?: ReactNode | ReactNode[] | undefined;
+  footer?: ReactNode | undefined;
+};
+
+export type Props<T extends ElementType> = Omit<
+  ComponentProps<T>,
+  keyof OwnProps
+> &
+  OwnProps;
+
+export const Card = (
+  props:
+    | Props<"div">
+    | Props<typeof UnstyledLink>
+    | Props<typeof UnstyledButton>,
+) => {
+  const overlayState = use(OverlayTriggerStateContext);
 
-type Props = ComponentProps<"div">;
+  if (overlayState !== null || "onPress" in props) {
+    return <UnstyledButton {...cardProps(props)} />;
+  } else if ("href" in props) {
+    return <UnstyledLink {...cardProps(props)} />;
+  } else {
+    return <div {...cardProps(props)} />;
+  }
+};
 
-export const Card = ({ className, ...props }: Props) => (
-  <div className={clsx(styles.card, className)} {...props} />
-);
+const cardProps = <T extends ElementType>({
+  className,
+  variant = "secondary",
+  children,
+  icon,
+  title,
+  toolbar,
+  footer,
+  ...props
+}: Props<T>) => ({
+  ...props,
+  "data-variant": variant,
+  className: clsx(styles.card, className),
+  children: (
+    <>
+      <div className={styles.cardHoverBackground} />
+      {(Boolean(icon) || Boolean(title) || Boolean(toolbar)) && (
+        <div className={styles.header}>
+          <h2 className={styles.title}>
+            {icon && <div className={styles.icon}>{icon}</div>}
+            {title}
+          </h2>
+          <div className={styles.toolbar}>{toolbar}</div>
+        </div>
+      )}
+      {children}
+      {footer && <div className={styles.footer}>{footer}</div>}
+    </>
+  ),
+});

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

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

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

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

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

@@ -0,0 +1,106 @@
+import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import clsx from "clsx";
+import { motion, AnimatePresence } from "motion/react";
+import {
+  type ComponentProps,
+  type ReactNode,
+  type ContextType,
+  use,
+  useCallback,
+} from "react";
+import {
+  Dialog,
+  Heading,
+  Modal as ModalComponent,
+  ModalOverlay as ModalOverlayComponent,
+  OverlayTriggerStateContext,
+} from "react-aria-components";
+
+import styles from "./index.module.scss";
+import { Button } from "../Button/index.js";
+
+export { DialogTrigger as DrawerTrigger } from "react-aria-components";
+
+const CLOSE_DURATION_IN_S = 0.15;
+export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
+
+// @ts-expect-error Looks like there's a typing mismatch currently between
+// motion and react, probably due to us being on react 19.  I'm expecting this
+// will go away when react 19 is officially stabilized...
+const ModalOverlay = motion.create(ModalOverlayComponent);
+// @ts-expect-error Looks like there's a typing mismatch currently between
+// motion and react, probably due to us being on react 19.  I'm expecting this
+// will go away when react 19 is officially stabilized...
+const Modal = motion.create(ModalComponent);
+
+type OwnProps = {
+  title: ReactNode;
+  children:
+    | ReactNode
+    | ((
+        state: NonNullable<ContextType<typeof OverlayTriggerStateContext>>,
+      ) => ReactNode);
+};
+
+type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
+
+export const Drawer = ({ title, children, className, ...props }: Props) => {
+  const state = use(OverlayTriggerStateContext);
+
+  const onOpenChange = useCallback(
+    (newValue: boolean) => {
+      state?.setOpen(newValue);
+    },
+    [state],
+  );
+
+  return (
+    <AnimatePresence>
+      {state?.isOpen && (
+        <ModalOverlay
+          isOpen
+          isDismissable
+          onOpenChange={onOpenChange}
+          initial={{ backgroundColor: "#00000000" }}
+          animate={{ backgroundColor: "#00000080" }}
+          exit={{ backgroundColor: "#00000000" }}
+          transition={{ ease: "linear", duration: CLOSE_DURATION_IN_S }}
+          className={styles.modalOverlay ?? ""}
+        >
+          <Modal
+            initial={{ x: "100%" }}
+            animate={{
+              x: 0,
+              transition: { type: "spring", duration: 1, bounce: 0.35 },
+            }}
+            exit={{
+              x: "100%",
+              transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+            }}
+            className={clsx(styles.modal, className)}
+          >
+            <Dialog className={styles.dialog ?? ""} {...props}>
+              <div className={styles.heading}>
+                <Heading className={styles.title} slot="title">
+                  {title}
+                </Heading>
+                <Button
+                  className={styles.closeButton ?? ""}
+                  beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+                  slot="close"
+                  hideText
+                  rounded
+                  variant="ghost"
+                  size="sm"
+                >
+                  Close
+                </Button>
+              </div>
+              {typeof children === "function" ? children(state) : children}
+            </Dialog>
+          </Modal>
+        </ModalOverlay>
+      )}
+    </AnimatePresence>
+  );
+};

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

@@ -6,6 +6,7 @@
   background: theme.color("background", "primary");
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  scroll-behavior: smooth;
 }
 
 *::selection {

+ 6 - 1
packages/component-library/src/Html/index.tsx

@@ -5,5 +5,10 @@ import type { ComponentProps } from "react";
 import "./base.scss";
 
 export const Html = ({ className, lang, ...props }: ComponentProps<"html">) => (
-  <html lang={lang} className={clsx(sans.className, className)} {...props} />
+  <html
+    lang={lang}
+    className={clsx(sans.className, className)}
+    data-gutter-stable
+    {...props}
+  />
 );

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

@@ -2,15 +2,21 @@
 
 .link {
   text-decoration: underline;
-  border: none;
+  border: 1px solid transparent;
   border-radius: theme.border-radius();
-  outline-width: 1px;
-  outline-offset: theme.spacing(1);
-  outline-color: transparent;
+  transition:
+    outline-color 100ms linear,
+    border-color 100ms linear;
+  outline: theme.spacing(1) solid transparent;
   color: inherit;
+  padding: theme.spacing(1);
+  margin: -#{theme.spacing(1)};
+  text-underline-offset: 0.125em;
+  outline-offset: 0;
 
   &[data-focus-visible] {
-    outline-color: currentcolor;
+    outline-color: theme.color("focus-dim");
+    border-color: theme.color("focus");
   }
 
   &[data-hovered] {

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

@@ -33,6 +33,11 @@ const meta = {
         disable: true,
       },
     },
+    className: {
+      table: {
+        disable: true,
+      },
+    },
     onPageChange: {
       table: {
         category: "Behavior",

+ 18 - 51
packages/component-library/src/Paginator/index.tsx

@@ -1,13 +1,7 @@
 import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
 import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
-import { CircleNotch } from "@phosphor-icons/react/dist/ssr/CircleNotch";
 import clsx from "clsx";
-import {
-  type ComponentProps,
-  useTransition,
-  useMemo,
-  useCallback,
-} from "react";
+import { type ComponentProps, useMemo, useCallback } from "react";
 
 import styles from "./index.module.scss";
 import { Button, ButtonLink } from "../Button/index.js";
@@ -63,38 +57,19 @@ const PageSizeSelect = ({
   pageSize,
   onPageSizeChange,
   pageSizeOptions,
-}: PageSizeSelectProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-
-  const onChange = useCallback(
-    (newPageSize: number) => {
-      startTransition(() => {
-        onPageSizeChange(newPageSize);
-      });
-    },
-    [startTransition, onPageSizeChange],
-  );
-
-  return (
-    <div className={styles.pageSizeSelect}>
-      <Select
-        label="Page size"
-        hideLabel
-        options={pageSizeOptions}
-        selectedKey={pageSize}
-        onSelectionChange={onChange}
-        show={(value) => `${value.toString()} per page`}
-        variant="ghost"
-        size="sm"
-      />
-      <CircleNotch
-        className={clsx(styles.loadingIndicator, {
-          [styles.visible ?? ""]: isTransitioning,
-        })}
-      />
-    </div>
-  );
-};
+}: PageSizeSelectProps) => (
+  <Select
+    className={styles.pageSizeSelect ?? ""}
+    label="Page size"
+    hideLabel
+    options={pageSizeOptions}
+    selectedKey={pageSize}
+    onSelectionChange={onPageSizeChange}
+    show={(value) => `${value.toString()} per page`}
+    variant="ghost"
+    size="sm"
+  />
+);
 
 type PaginatorProps = {
   numPages: number;
@@ -209,13 +184,9 @@ const PageLink = ({
   mkPageLink,
   ...props
 }: PageLinkProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-
   const url = useMemo(() => mkPageLink(page), [page, mkPageLink]);
   const onPress = useCallback(() => {
-    startTransition(() => {
-      onPageChange(page);
-    });
+    onPageChange(page);
   }, [onPageChange, page]);
 
   return (
@@ -224,7 +195,7 @@ const PageLink = ({
       size="sm"
       onPress={onPress}
       href={url}
-      isDisabled={isDisabled === true || isTransitioning}
+      isDisabled={isDisabled === true}
       {...props}
     />
   );
@@ -244,12 +215,8 @@ const PageButton = ({
   onPageChange,
   ...props
 }: PageButtonProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-
   const onPress = useCallback(() => {
-    startTransition(() => {
-      onPageChange(page);
-    });
+    onPageChange(page);
   }, [onPageChange, page]);
 
   return (
@@ -257,7 +224,7 @@ const PageButton = ({
       variant="ghost"
       size="sm"
       onPress={onPress}
-      isDisabled={isDisabled === true || isTransitioning}
+      isDisabled={isDisabled === true}
       {...props}
     />
   );

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

@@ -100,7 +100,7 @@
       .input {
         border-radius: theme.map-get-strict($values, "border-radius");
         font-size: theme.map-get-strict($values, "font-size");
-        line-height: calc($height - 2px);
+        line-height: $height;
         padding: 0 ($padding + $icon-size + $gap);
       }
 

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

@@ -1,3 +1,5 @@
+"use client";
+
 import { CircleNotch } from "@phosphor-icons/react/dist/ssr/CircleNotch";
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";

+ 90 - 13
packages/component-library/src/Select/index.module.scss

@@ -19,23 +19,47 @@
 }
 
 .popover {
-  // data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out"
+  min-width: var(--trigger-width);
+  background-color: theme.color("background", "modal");
+  border-radius: theme.border-radius("lg");
+  border: 1px solid theme.color("border");
+  color: theme.color("paragraph");
+  display: flex;
+  flex-flow: column nowrap;
+  font-size: theme.font-size("sm");
+  box-shadow:
+    0 4px 6px -4px rgb(from black r g b / 10%),
+    0 10px 15px -3px rgb(from black r g b / 10%);
+
+  .title {
+    padding: theme.spacing(3);
+    padding-bottom: theme.spacing(1);
+    color: theme.color("muted");
+    line-height: theme.spacing(4);
+    font-size: theme.font-size("xxs");
+    font-weight: theme.font-weight("medium");
+  }
 
   .listbox {
-    min-width: var(--trigger-width);
-    background-color: theme.color("background", "modal");
-    border-radius: theme.border-radius("lg");
-    border: 1px solid theme.color("border");
-    color: theme.color("paragraph");
+    outline: none;
+    overflow: auto;
     padding: theme.spacing(1);
-    display: flex;
-    flex-flow: column nowrap;
-    font-size: theme.font-size("sm");
-    box-shadow:
-      0 4px 6px -4px rgb(0 0 0 / 10%),
-      0 10px 15px -3px rgb(0 0 0 / 10%);
 
-    // origin-top-right"
+    .section {
+      padding: theme.spacing(0.5) 0;
+
+      &:first-child {
+        padding-top: 0;
+      }
+
+      &:last-child {
+        padding-bottom: 0;
+      }
+
+      &:not(:first-child) {
+        border-top: 1px solid theme.color("border");
+      }
+    }
 
     .listboxItem {
       padding: theme.spacing(2);
@@ -54,6 +78,7 @@
       border: 1px solid transparent;
       outline: theme.spacing(0.5) solid transparent;
       outline-offset: 0;
+      line-height: theme.spacing(4);
 
       .check {
         width: theme.spacing(3);
@@ -84,4 +109,56 @@
       }
     }
   }
+
+  &[data-group-label-hidden] .groupLabel {
+    @include theme.sr-only;
+  }
+
+  &[data-placement="top"] {
+    --origin: translateY(8px);
+    --scale: 1, 0.8;
+
+    transform-origin: bottom;
+  }
+
+  &[data-placement="bottom"] {
+    --origin: translateY(-8px);
+    --scale: 1, 0.8;
+
+    transform-origin: top;
+  }
+
+  &[data-placement="right"] {
+    --origin: translateX(-8px);
+    --scale: 0.8, 1;
+
+    transform-origin: left;
+  }
+
+  &[data-placement="left"] {
+    --origin: translateX(8px);
+    --scale: 0.8, 1;
+
+    transform-origin: right;
+  }
+
+  &[data-entering] {
+    animation: popover-slide 200ms;
+  }
+
+  &[data-exiting] {
+    animation: popover-slide 200ms reverse ease-in;
+  }
+}
+
+@keyframes popover-slide {
+  from {
+    transform: scale(var(--scale)) var(--origin);
+    opacity: 0;
+  }
+
+  to {
+    transform: scale(1, 1) translateY(0);
+    opacity: 1;
+  }
 }

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

@@ -3,8 +3,6 @@ import type { Meta, StoryObj } from "@storybook/react";
 import { Select as SelectComponent } from "./index.js";
 import buttonMeta from "../Button/index.stories.js";
 
-const OPTIONS = ["foo", "bar", "baz"];
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const { children, beforeIcon, onPress, ...argTypes } = buttonMeta.argTypes;
 const meta = {
@@ -29,6 +27,11 @@ const meta = {
         disable: true,
       },
     },
+    optionGroups: {
+      table: {
+        disable: true,
+      },
+    },
     defaultSelectedKey: {
       table: {
         disable: true,
@@ -74,14 +77,49 @@ const meta = {
         category: "Behavior",
       },
     },
+    buttonLabel: {
+      control: "text",
+      table: {
+        category: "Label",
+      },
+    },
   },
 } satisfies Meta<typeof SelectComponent>;
 export default meta;
 
-export const Select = {
+export const Flat = {
+  args: {
+    defaultSelectedKey: "foo",
+    options: ["foo", "bar", "baz"],
+    variant: "primary",
+    size: "md",
+    isDisabled: false,
+    isPending: false,
+    rounded: false,
+    hideText: false,
+    show: (value) => `The option ${value.toString()}`,
+    label: "A SELECT!",
+    hideLabel: true,
+    buttonLabel: "",
+  },
+} satisfies StoryObj<typeof SelectComponent>;
+
+export const Grouped = {
+  argTypes: {
+    hideGroupLabel: {
+      control: "boolean",
+      table: {
+        category: "Contents",
+      },
+    },
+  },
   args: {
     defaultSelectedKey: "foo",
-    options: OPTIONS,
+    optionGroups: [
+      { name: "All", options: ["foo1", "foo2", "Some"] },
+      { name: "bars", options: ["bar1", "bar2", "bar3"] },
+      { name: "bazzes", options: ["baz1", "baz2", "baz3"] },
+    ],
     variant: "primary",
     size: "md",
     isDisabled: false,
@@ -89,7 +127,9 @@ export const Select = {
     rounded: false,
     hideText: false,
     show: (value) => `The option ${value.toString()}`,
-    label: "A Select!",
+    label: "FOOS AND BARS",
     hideLabel: true,
+    hideGroupLabel: true,
+    buttonLabel: "",
   },
 } satisfies StoryObj<typeof SelectComponent>;

+ 69 - 23
packages/component-library/src/Select/index.tsx

@@ -8,6 +8,9 @@ import {
   Popover,
   ListBox,
   ListBoxItem,
+  ListBoxSection,
+  Header,
+  Collection,
   SelectValue,
 } from "react-aria-components";
 
@@ -23,24 +26,32 @@ type Props<T> = Omit<
     "variant" | "size" | "rounded" | "hideText" | "isPending"
   > &
   Pick<PopoverProps, "placement"> & {
-    options: readonly T[];
     show?: (value: T) => string;
     icon?: ComponentProps<typeof Button>["beforeIcon"];
     label: ReactNode;
     hideLabel?: boolean | undefined;
+    buttonLabel?: ReactNode;
   } & (
     | {
-        defaultSelectedKey: T;
+        defaultSelectedKey?: T | undefined;
       }
     | {
         selectedKey: T;
         onSelectionChange: (newValue: T) => void;
       }
+  ) &
+  (
+    | {
+        options: readonly T[];
+      }
+    | {
+        hideGroupLabel?: boolean | undefined;
+        optionGroups: { name: string; options: readonly T[] }[];
+      }
   );
 
 export const Select = <T extends string | number>({
   className,
-  options,
   show,
   variant,
   size,
@@ -51,6 +62,7 @@ export const Select = <T extends string | number>({
   hideLabel,
   placement,
   isPending,
+  buttonLabel,
   ...props
 }: Props<T>) => (
   // @ts-expect-error react-aria coerces everything to Key for some reason...
@@ -72,31 +84,65 @@ export const Select = <T extends string | number>({
       beforeIcon={icon}
       isPending={isPending === true}
     >
-      <SelectValue<{ id: T }>>
-        {({ selectedItem }) =>
-          selectedItem ? (show?.(selectedItem.id) ?? selectedItem.id) : <></>
-        }
-      </SelectValue>
+      {buttonLabel !== undefined && buttonLabel !== "" ? (
+        buttonLabel
+      ) : (
+        <SelectValue<{ id: T }>>
+          {({ selectedItem, selectedText }) =>
+            selectedItem
+              ? (show?.(selectedItem.id) ?? selectedItem.id)
+              : selectedText
+          }
+        </SelectValue>
+      )}
     </Button>
-    <Popover {...(placement && { placement })} className={styles.popover ?? ""}>
-      <ListBox
-        className={styles.listbox ?? ""}
-        items={options.map((id) => ({ id }))}
-      >
-        {({ id }) => (
-          <ListBoxItem
-            className={styles.listboxItem ?? ""}
-            textValue={show?.(id) ?? id.toString()}
-          >
-            <span>{show?.(id) ?? id}</span>
-            <Check weight="bold" className={styles.check} />
-          </ListBoxItem>
-        )}
-      </ListBox>
+    <Popover
+      {...(placement && { placement })}
+      data-group-label-hidden={
+        "hideGroupLabel" in props && props.hideGroupLabel ? "" : undefined
+      }
+      className={styles.popover ?? ""}
+    >
+      <span className={styles.title}>{label}</span>
+      {"options" in props ? (
+        <ListBox
+          className={styles.listbox ?? ""}
+          items={props.options.map((id) => ({ id }))}
+        >
+          {({ id }) => <Item show={show}>{id}</Item>}
+        </ListBox>
+      ) : (
+        <ListBox className={styles.listbox ?? ""} items={props.optionGroups}>
+          {({ name, options }) => (
+            <ListBoxSection className={styles.section ?? ""} id={name}>
+              <Header className={styles.groupLabel ?? ""}>{name}</Header>
+              <Collection items={options.map((id) => ({ id }))}>
+                {({ id }) => <Item show={show}>{id}</Item>}
+              </Collection>
+            </ListBoxSection>
+          )}
+        </ListBox>
+      )}
     </Popover>
   </BaseSelect>
 );
 
+type ItemProps<T> = {
+  children: T;
+  show: ((value: T) => string) | undefined;
+};
+
+const Item = <T extends string | number>({ children, show }: ItemProps<T>) => (
+  <ListBoxItem
+    id={children}
+    className={styles.listboxItem ?? ""}
+    textValue={show?.(children) ?? children.toString()}
+  >
+    <span>{show?.(children) ?? children}</span>
+    <Check weight="bold" className={styles.check} />
+  </ListBoxItem>
+);
+
 const DropdownCaretDown = (
   props: Omit<ComponentProps<"svg">, "xmlns" | "viewBox" | "fill">,
 ) => (

+ 27 - 0
packages/component-library/src/Skeleton/index.module.scss

@@ -14,4 +14,31 @@
       @include theme.sr-only;
     }
   }
+
+  &[data-round] {
+    border-radius: theme.border-radius("full");
+    display: inline-block;
+    width: calc(theme.spacing(1) * var(--skeleton-width));
+    height: calc(theme.spacing(1) * var(--skeleton-width));
+
+    .skeletonInner {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  &[data-fill] {
+    .skeletonInner {
+      width: 100%;
+    }
+
+    &[data-round] {
+      width: 100%;
+      height: 100%;
+
+      .skeletonInner {
+        height: 100%;
+      }
+    }
+  }
 }

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

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

+ 16 - 8
packages/component-library/src/Skeleton/index.tsx

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

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

@@ -0,0 +1,36 @@
+@use "../theme";
+
+.statCard {
+  height: theme.spacing(22);
+
+  .cardContents {
+    display: flex;
+    flex-flow: column nowrap;
+    justify-content: space-between;
+    height: 100%;
+    padding: theme.spacing(3);
+    padding-bottom: theme.spacing(2);
+
+    .header {
+      color: theme.color("muted");
+      text-align: left;
+
+      @include theme.text("sm", "medium");
+    }
+
+    .bottom {
+      display: flex;
+      flex-flow: row nowrap;
+      justify-content: space-between;
+      align-items: center;
+
+      .stat {
+        @include theme.h3;
+      }
+
+      .miniStat {
+        @include theme.text("sm", "medium");
+      }
+    }
+  }
+}

+ 51 - 0
packages/component-library/src/StatCard/index.stories.tsx

@@ -0,0 +1,51 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { StatCard as StatCardComponent } from "./index.js";
+import cardMeta, { Card as CardStory } from "../Card/index.stories.js";
+
+const cardMetaArgTypes = () => {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const { title, toolbar, icon, footer, ...argTypes } = cardMeta.argTypes;
+  return argTypes;
+};
+
+const meta = {
+  component: StatCardComponent,
+  argTypes: {
+    ...cardMetaArgTypes(),
+    header: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    stat: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    miniStat: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+  },
+} satisfies Meta<typeof StatCardComponent>;
+export default meta;
+
+const cardStoryArgs = () => {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const { title, toolbar, footer, ...args } = CardStory.args;
+  return args;
+};
+
+export const StatCard = {
+  args: {
+    ...cardStoryArgs(),
+    header: "Active Feeds",
+    stat: "552",
+    miniStat: "+5",
+  },
+} satisfies StoryObj<typeof StatCardComponent>;

+ 32 - 0
packages/component-library/src/StatCard/index.tsx

@@ -0,0 +1,32 @@
+import clsx from "clsx";
+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"
+> & {
+  header: ReactNode;
+  stat: ReactNode;
+  miniStat?: ReactNode | undefined;
+};
+
+export const StatCard = <T extends ElementType>({
+  header,
+  stat,
+  miniStat,
+  className,
+  ...props
+}: Props<T>) => (
+  <Card className={clsx(styles.statCard, className)} {...props}>
+    <div className={styles.cardContents}>
+      <h2 className={styles.header}>{header}</h2>
+      <div className={styles.bottom}>
+        <div className={styles.stat}>{stat}</div>
+        {miniStat && <div className={styles.miniStat}>{miniStat}</div>}
+      </div>
+    </div>
+  </Card>
+);

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

@@ -2,7 +2,6 @@
 
 .tableContainer {
   background-color: theme.color("background", "primary");
-  border-radius: theme.border-radius("xl");
   position: relative;
 
   .loaderWrapper {
@@ -40,11 +39,12 @@
     border-collapse: collapse;
 
     .cell {
-      padding-left: theme.spacing(2);
-      padding-right: theme.spacing(2);
+      padding-left: theme.spacing(3);
+      padding-right: theme.spacing(3);
       white-space: nowrap;
       border: 0;
       outline: none;
+      width: calc(theme.spacing(1) * var(--width));
 
       &:first-child {
         padding-left: theme.spacing(4);
@@ -89,7 +89,7 @@
     }
 
     .tableBody {
-      font-size: theme.font-size("sm");
+      @include theme.text("sm", "medium");
 
       .row {
         background-color: transparent;
@@ -128,18 +128,34 @@
           padding-top: theme.spacing(4);
           padding-bottom: theme.spacing(4);
         }
+      }
+    }
+  }
 
-        &:last-child {
-          .cell {
-            &:first-child {
-              border-bottom-left-radius: theme.border-radius("xl");
-            }
+  &[data-fill] .table {
+    width: 100%;
+  }
 
-            &:last-child {
-              border-bottom-right-radius: theme.border-radius("xl");
-            }
-          }
-        }
+  &[data-divide] {
+    .tableHeader {
+      border-color: theme.color("border");
+    }
+
+    .tableBody .row .cell {
+      border-bottom: 1px solid theme.color("background", "secondary");
+    }
+  }
+
+  &[data-rounded] {
+    border-radius: theme.border-radius("xl");
+
+    .tableBody .row:last-child .cell {
+      &:first-child {
+        border-bottom-left-radius: theme.border-radius("xl");
+      }
+
+      &:last-child {
+        border-bottom-right-radius: theme.border-radius("xl");
       }
     }
   }

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

@@ -20,6 +20,11 @@ const meta = {
         disable: true,
       },
     },
+    className: {
+      table: {
+        disable: true,
+      },
+    },
     label: {
       table: {
         category: "Accessibility",
@@ -37,6 +42,29 @@ const meta = {
         category: "State",
       },
     },
+    divide: {
+      control: "boolean",
+      table: {
+        category: "Variant",
+      },
+    },
+    fill: {
+      control: "boolean",
+      table: {
+        category: "Variant",
+      },
+    },
+    rounded: {
+      control: "boolean",
+      table: {
+        category: "Variant",
+      },
+    },
+    dependencies: {
+      table: {
+        disable: true,
+      },
+    },
   },
 } satisfies Meta<typeof TableComponent>;
 export default meta;
@@ -46,6 +74,9 @@ export const Table = {
     label: "A Table",
     isUpdating: false,
     isLoading: false,
+    fill: true,
+    divide: false,
+    rounded: true,
     columns: [
       {
         name: "PRICE FEED",

+ 89 - 69
packages/component-library/src/Table/index.tsx

@@ -1,8 +1,7 @@
 "use client";
 
-import { useDebouncedEffect } from "@react-hookz/web";
 import clsx from "clsx";
-import { type ReactNode, useState } from "react";
+import type { CSSProperties, ReactNode } from "react";
 import type {
   RowProps,
   ColumnProps,
@@ -21,25 +20,35 @@ import {
 } from "../UnstyledTable/index.js";
 
 type TableProps<T extends string> = {
+  className?: string | undefined;
+  fill?: boolean | undefined;
+  divide?: boolean | undefined;
+  rounded?: boolean | undefined;
   label: string;
   columns: ColumnConfig<T>[];
-  rows: RowConfig<T>[];
   isLoading?: boolean | undefined;
   isUpdating?: boolean | undefined;
-  renderEmptyState?: TableBodyProps<T>["renderEmptyState"];
-};
+  renderEmptyState?: TableBodyProps<T>["renderEmptyState"] | undefined;
+  dependencies?: TableBodyProps<T>["dependencies"] | undefined;
+} & (
+  | { isLoading: true; rows?: RowConfig<T>[] | undefined }
+  | { isLoading?: false | undefined; rows: RowConfig<T>[] }
+);
 
 export type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
   name: ReactNode;
   id: T;
   fill?: boolean | undefined;
   alignment?: Alignment | undefined;
-  loadingSkeletonWidth?: number | undefined;
-};
+  width?: number | undefined;
+} & (
+    | { loadingSkeleton?: ReactNode }
+    | { loadingSkeletonWidth?: number | undefined }
+  );
 
 type Alignment = "left" | "center" | "right" | undefined;
 
-type RowConfig<T extends string> = Omit<
+export type RowConfig<T extends string> = Omit<
   RowProps<T>,
   "columns" | "children" | "value"
 > & {
@@ -48,85 +57,96 @@ type RowConfig<T extends string> = Omit<
 };
 
 export const Table = <T extends string>({
+  className,
+  fill,
+  divide,
+  rounded,
   label,
   rows,
   columns,
   isLoading,
   isUpdating,
   renderEmptyState,
-}: TableProps<T>) => {
-  const [debouncedRows, setDebouncedRows] = useState(rows);
-
-  useDebouncedEffect(
-    () => {
-      setDebouncedRows(rows);
-    },
-    [rows],
-    500,
-  );
-
-  return (
-    <div className={styles.tableContainer}>
-      {isUpdating && (
-        <div className={styles.loaderWrapper}>
-          <div className={styles.loader} />
-        </div>
-      )}
-      <UnstyledTable aria-label={label} className={styles.table ?? ""}>
-        <UnstyledTableHeader
-          columns={columns}
-          className={styles.tableHeader ?? ""}
-        >
-          {({ fill, alignment, ...column }: ColumnConfig<T>) => (
-            <UnstyledColumn {...cellProps(alignment, fill)} {...column}>
-              {column.name}
-            </UnstyledColumn>
-          )}
-        </UnstyledTableHeader>
-        <UnstyledTableBody
-          items={isLoading ? [] : debouncedRows}
-          className={styles.tableBody ?? ""}
-          {...(renderEmptyState !== undefined && { renderEmptyState })}
-        >
-          {isLoading ? (
+  dependencies,
+}: TableProps<T>) => (
+  <div
+    className={clsx(styles.tableContainer, className)}
+    data-fill={fill ? "" : undefined}
+    data-divide={divide ? "" : undefined}
+    data-rounded={rounded ? "" : undefined}
+  >
+    {isUpdating && (
+      <div className={styles.loaderWrapper}>
+        <div className={styles.loader} />
+      </div>
+    )}
+    <UnstyledTable aria-label={label} className={styles.table ?? ""}>
+      <UnstyledTableHeader
+        columns={columns}
+        className={styles.tableHeader ?? ""}
+      >
+        {({ fill, width, alignment, ...column }: ColumnConfig<T>) => (
+          <UnstyledColumn {...cellProps(alignment, width, fill)} {...column}>
+            {column.name}
+          </UnstyledColumn>
+        )}
+      </UnstyledTableHeader>
+      <UnstyledTableBody
+        items={isLoading ? [] : rows}
+        className={styles.tableBody ?? ""}
+        {...(dependencies !== undefined && { dependencies })}
+        {...(renderEmptyState !== undefined && { renderEmptyState })}
+      >
+        {isLoading ? (
+          <UnstyledRow
+            id="loading"
+            key="loading"
+            className={styles.row ?? ""}
+            columns={columns}
+          >
+            {({ alignment, fill, width, ...column }: ColumnConfig<T>) => (
+              <UnstyledCell {...cellProps(alignment, width, fill)}>
+                {"loadingSkeleton" in column ? (
+                  column.loadingSkeleton
+                ) : (
+                  <Skeleton
+                    width={
+                      "loadingSkeletonWidth" in column
+                        ? column.loadingSkeletonWidth
+                        : undefined
+                    }
+                  />
+                )}
+              </UnstyledCell>
+            )}
+          </UnstyledRow>
+        ) : (
+          ({ className: rowClassName, data, ...row }: RowConfig<T>) => (
             <UnstyledRow
-              id="loading"
-              key="loading"
-              className={styles.row ?? ""}
+              className={clsx(styles.row, rowClassName)}
               columns={columns}
+              {...row}
             >
-              {({ alignment, fill, loadingSkeletonWidth }: ColumnConfig<T>) => (
-                <UnstyledCell {...cellProps(alignment, fill)}>
-                  <Skeleton width={loadingSkeletonWidth ?? 10} />
+              {({ alignment, width, fill, id }: ColumnConfig<T>) => (
+                <UnstyledCell {...cellProps(alignment, width, fill)}>
+                  {data[id]}
                 </UnstyledCell>
               )}
             </UnstyledRow>
-          ) : (
-            ({ className: rowClassName, data, ...row }: RowConfig<T>) => (
-              <UnstyledRow
-                className={clsx(styles.row, rowClassName)}
-                columns={columns}
-                {...row}
-              >
-                {({ alignment, fill, id }: ColumnConfig<T>) => (
-                  <UnstyledCell {...cellProps(alignment, fill)}>
-                    {data[id]}
-                  </UnstyledCell>
-                )}
-              </UnstyledRow>
-            )
-          )}
-        </UnstyledTableBody>
-      </UnstyledTable>
-    </div>
-  );
-};
+          )
+        )}
+      </UnstyledTableBody>
+    </UnstyledTable>
+  </div>
+);
 
 const cellProps = (
   alignment: Alignment | undefined,
+  width: number | undefined,
   fill: boolean | undefined,
 ) => ({
   className: styles.cell ?? "",
   "data-alignment": alignment ?? "left",
+  ...(width && { style: { "--width": width } as CSSProperties }),
   ...(fill && { "data-fill": "" }),
 });

+ 0 - 32
packages/component-library/src/TableCard/index.module.scss

@@ -1,32 +0,0 @@
-@use "../theme";
-
-.tableCard {
-  .header {
-    display: flex;
-    flex-flow: row nowrap;
-    justify-content: space-between;
-    align-items: center;
-    padding: theme.spacing(4);
-
-    .title {
-      margin: 0;
-      font-size: theme.font-size("lg");
-      font-weight: theme.font-weight("medium");
-      color: theme.color("heading");
-      display: inline-flex;
-      flex-flow: row nowrap;
-      gap: theme.spacing(3);
-      align-items: center;
-
-      .icon {
-        width: theme.spacing(6);
-        height: theme.spacing(6);
-        color: theme.color("button", "primary", "background", "normal");
-      }
-    }
-  }
-
-  .footer {
-    padding: theme.spacing(2);
-  }
-}

+ 0 - 51
packages/component-library/src/TableCard/index.stories.tsx

@@ -1,51 +0,0 @@
-import * as Icon from "@phosphor-icons/react/dist/ssr";
-import type { Meta, StoryObj } from "@storybook/react";
-
-import { TableCard as TableCardComponent } from "./index.js";
-import tableMeta, { Table as TableStory } from "../Table/index.stories.js";
-
-const meta = {
-  component: TableCardComponent,
-  parameters: {
-    backgrounds: {
-      disable: true,
-    },
-  },
-  argTypes: {
-    ...tableMeta.argTypes,
-    title: {
-      control: "text",
-      table: {
-        category: "Card",
-      },
-    },
-    toolbar: {
-      table: {
-        disable: true,
-      },
-    },
-    footer: {
-      table: {
-        disable: true,
-      },
-    },
-    icon: {
-      control: "select",
-      options: Object.keys(Icon),
-      mapping: Icon,
-      table: {
-        category: "Contents",
-      },
-    },
-  },
-} satisfies Meta<typeof TableCardComponent>;
-export default meta;
-
-export const TableCard = {
-  args: {
-    ...TableStory.args,
-    title: "A Table",
-    toolbar: <div>A toolbar</div>,
-    footer: <div>A footer</div>,
-  },
-} satisfies StoryObj<typeof TableCardComponent>;

+ 0 - 32
packages/component-library/src/TableCard/index.tsx

@@ -1,32 +0,0 @@
-import type { ComponentType, ComponentProps, ReactNode } from "react";
-
-import styles from "./index.module.scss";
-import { Card } from "../Card/index.js";
-import { Table } from "../Table/index.js";
-
-type Props<T extends string> = ComponentProps<typeof Table<T>> & {
-  icon?: ComponentType<{ className?: string | undefined }> | undefined;
-  title?: ReactNode | undefined;
-  footer?: ReactNode | undefined;
-  toolbar?: ReactNode | ReactNode[] | undefined;
-};
-
-export const TableCard = <T extends string>({
-  icon: Icon,
-  title,
-  footer,
-  toolbar,
-  ...props
-}: Props<T>) => (
-  <Card className={styles.tableCard}>
-    <div className={styles.header}>
-      <h2 className={styles.title}>
-        {Icon && <Icon className={styles.icon} />}
-        {title ?? props.label}
-      </h2>
-      {toolbar}
-    </div>
-    <Table {...props} />
-    {footer && <div className={styles.footer}>{footer}</div>}
-  </Card>
-);

+ 112 - 12
packages/component-library/src/theme.scss

@@ -47,10 +47,23 @@ $font-size: (
   "9xl": 8rem,
 );
 
-@function font-size($size: "$base") {
+@function font-size($size: "base") {
   @return map-get-strict($font-size, $size);
 }
 
+$letter-spacing: (
+  "tighter": -0.05em,
+  "tight": -0.025em,
+  "normal": 0em,
+  "wide": 0.025em,
+  "wider": 0.05em,
+  "widest": 0.1em,
+);
+
+@function letter-spacing($spacing: "normal") {
+  @return map-get-strict($letter-spacing, $spacing);
+}
+
 $border-radius: (
   "none": 0px,
   "sm": 0.125rem,
@@ -378,9 +391,9 @@ $color-pallette: (
     500: #64678b,
     600: #474a69,
     700: #333655,
-    800: #1e1f3b,
-    900: #100f2a,
-    950: #050217,
+    800: #25253e,
+    900: #27253d,
+    950: #100e23,
   ),
 );
 
@@ -392,9 +405,18 @@ $color: (
   "transparent": transparent,
   "background": (
     "primary": light-dark(pallette-color("white"), pallette-color("steel", 950)),
+    "nav-blur":
+      light-dark(
+        rgb(from pallette-color("white") r g b / 70%),
+        rgb(from pallette-color("steel", 950) r g b / 70%)
+      ),
     "modal": light-dark(pallette-color("white"), pallette-color("steel", 950)),
     "secondary":
       light-dark(pallette-color("beige", 100), pallette-color("steel", 900)),
+    "card-highlight":
+      light-dark(pallette-color("violet", 100), pallette-color("violet", 950)),
+    "card-secondary":
+      light-dark(pallette-color("white"), pallette-color("steel", 950)),
   ),
   "foreground":
     light-dark(pallette-color("steel", 900), pallette-color("steel", 50)),
@@ -403,7 +425,7 @@ $color: (
   "paragraph":
     light-dark(pallette-color("steel", 700), pallette-color("steel", 300)),
   "muted":
-    light-dark(pallette-color("stone", 500), pallette-color("steel", 400)),
+    light-dark(pallette-color("stone", 700), pallette-color("steel", 300)),
   "border":
     light-dark(pallette-color("stone", 300), pallette-color("steel", 600)),
   "selection": (
@@ -414,11 +436,17 @@ $color: (
   ),
   "states": (
     "success": (
+      "base":
+        light-dark(
+          pallette-color("emerald", 600),
+          pallette-color("emerald", 500)
+        ),
       "normal": pallette-color("emerald", 500),
       "hover": pallette-color("emerald", 600),
       "active": pallette-color("emerald", 700),
     ),
     "error": (
+      "base": light-dark(pallette-color("red", 600), pallette-color("red", 400)),
       "normal": pallette-color("red", 500),
       "hover": pallette-color("red", 600),
       "active": pallette-color("red", 700),
@@ -427,13 +455,25 @@ $color: (
       "normal":
         light-dark(pallette-color("steel", 900), pallette-color("steel", 50)),
     ),
+    "info": (
+      "normal":
+        light-dark(pallette-color("indigo", 600), pallette-color("indigo", 400)),
+    ),
+    "warning": (
+      "normal":
+        light-dark(pallette-color("orange", 600), pallette-color("orange", 400)),
+    ),
+    "data": (
+      "normal":
+        light-dark(pallette-color("violet", 600), pallette-color("violet", 400)),
+    ),
   ),
   "focus":
     light-dark(pallette-color("violet", 700), pallette-color("violet", 500)),
   "focus-dim":
     light-dark(
-      rgba(pallette-color("violet", 700), 0.3),
-      rgba(pallette-color("violet", 500), 0.3)
+      rgb(from pallette-color("violet", 700) r g b / 30%),
+      rgb(from pallette-color("violet", 500) r g b / 30%)
     ),
   "forms": (
     "input": (
@@ -491,13 +531,13 @@ $color: (
       "background": (
         "hover":
           light-dark(
-            rgba(pallette-color("beige", 950), 0.05),
-            rgba(pallette-color("steel", 50), 0.05)
+            rgb(from pallette-color("beige", 950) r g b / 5%),
+            rgb(from pallette-color("steel", 50) r g b / 5%)
           ),
         "active":
           light-dark(
-            rgba(pallette-color("beige", 950), 0.1),
-            rgba(pallette-color("steel", 50), 0.1)
+            rgb(from pallette-color("beige", 950) r g b / 10%),
+            rgb(from pallette-color("steel", 50) r g b / 10%)
           ),
       ),
     ),
@@ -605,9 +645,11 @@ $button-sizes: (
   }
 }
 
+$max-width: 96rem;
+
 @mixin max-width {
   margin: 0 auto;
-  max-width: 1536px;
+  max-width: $max-width;
   padding: 0 spacing(6);
   box-sizing: content-box;
 }
@@ -617,3 +659,61 @@ $button-sizes: (
   flex-flow: row nowrap;
   align-items: center;
 }
+
+$elevations: (
+  primary: (
+    2: (
+      0px 66px 18px 0px rgb(112 66 206 / 0%),
+      0px 42px 17px 0px rgb(112 66 206 / 3%),
+      0px 24px 14px 0px rgb(112 66 206 / 8%),
+      0px 11px 11px 0px rgb(112 66 206 / 14%),
+      0px 3px 6px 0px rgb(112 66 206 / 17%),
+    ),
+  ),
+  default: (
+    1: (
+      0px 4px 6px -4px rgb(from black r g b / 10%),
+      0px 10px 15px -3px rgb(from black r g b / 10%),
+    ),
+    2: (
+      0px 29px 12px 0px
+        light-dark(rgb(from #564848 r g b / 2%), rgb(from black r g b / 8%)),
+      0px 16px 10px 0px
+        light-dark(rgb(from #564848 r g b / 6%), rgb(from black r g b / 12%)),
+      0px 7px 7px 0px
+        light-dark(rgb(from #564848 r g b / 12%), rgb(from black r g b / 20%)),
+      0px 2px 4px 0px
+        light-dark(rgb(from #564848 r g b / 14%), rgb(from black r g b / 30%)),
+    ),
+  ),
+);
+
+@mixin elevation($elevation...) {
+  box-shadow: map-get-strict($elevations, $elevation...);
+}
+
+@mixin h3 {
+  font-size: font-size("2xl");
+  font-style: normal;
+  font-weight: font-weight("medium");
+  line-height: 125%;
+  letter-spacing: letter-spacing("tighter");
+  margin: 0;
+}
+
+@mixin h4 {
+  font-size: font-size("xl");
+  font-style: normal;
+  font-weight: font-weight("medium");
+  line-height: 125%;
+  letter-spacing: letter-spacing("tight");
+  margin: 0;
+}
+
+@mixin text($size: "base", $weight: "normal") {
+  font-size: font-size($size);
+  font-weight: font-weight($weight);
+  margin: 0;
+  font-style: normal;
+  line-height: normal;
+}

+ 1 - 1
packages/next-root/src/index.tsx

@@ -34,10 +34,10 @@ export const Root = ({
 }: Props) => (
   <ComposeProviders
     providers={[
+      ...(providers ?? []),
       LoggerProvider,
       I18nProvider,
       RouterProvider,
-      ...(providers ?? []),
     ]}
   >
     <HtmlWithLang

Різницю між файлами не показано, бо вона завелика
+ 383 - 329
pnpm-lock.yaml


+ 4 - 2
pnpm-workspace.yaml

@@ -60,6 +60,7 @@ catalog:
   "@types/react-dom": npm:types-react-dom@19.0.0-rc.1
   autoprefixer: 10.4.20
   bcp-47: 2.1.0
+  bs58: 6.0.0
   clsx: 2.1.1
   cryptocurrency-icons: 0.18.1
   css-loader: 7.1.2
@@ -75,8 +76,8 @@ catalog:
   postcss-loader: 8.1.1
   postcss: 8.4.47
   prettier: 3.3.3
-  react-aria-components: 1.4.0
-  react-aria: 3.35.0
+  react-aria-components: 1.5.0
+  react-aria: 3.36.0
   react-dom: 19.0.0-rc-603e6108-20241029
   react: 19.0.0-rc-603e6108-20241029
   sass-loader: 16.0.3
@@ -85,6 +86,7 @@ catalog:
   style-loader: 4.0.0
   stylelint-config-standard-scss: 13.1.0
   stylelint: 16.10.0
+  swr: 2.2.5
   tailwindcss-animate: 1.0.7
   tailwindcss-react-aria-components: 1.1.6
   tailwindcss: 3.4.14

Деякі файли не було показано, через те що забагато файлів було змінено