Преглед изворни кода

feat(insights): build header & tabs for price feed details page

Connor Prussin пре 11 месеци
родитељ
комит
b968742c10
70 измењених фајлова са 1896 додато и 815 уклоњено
  1. 17 0
      apps/api-reference/jsx.d.ts
  2. 1 1
      apps/api-reference/next-env.d.ts
  3. 0 3
      apps/api-reference/src/markdown-components.tsx
  4. 1 1
      apps/insights/next-env.d.ts
  5. 1 1
      apps/insights/package.json
  6. 13 0
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  7. 1 0
      apps/insights/src/app/price-feeds/[slug]/page.ts
  8. 1 0
      apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx
  9. 1 0
      apps/insights/src/app/price-feeds/layout.ts
  10. 6 0
      apps/insights/src/app/price-feeds/page.ts
  11. 6 0
      apps/insights/src/app/publishers/page.ts
  12. 0 21
      apps/insights/src/components/AsyncValue/index.tsx
  13. 0 1
      apps/insights/src/components/ChangePercent/index.module.scss
  14. 43 26
      apps/insights/src/components/ChangePercent/index.tsx
  15. 6 32
      apps/insights/src/components/CopyButton/index.module.scss
  16. 18 21
      apps/insights/src/components/CopyButton/index.tsx
  17. 45 0
      apps/insights/src/components/FeedKey/index.tsx
  18. 4 4
      apps/insights/src/components/FormattedTokens/index.tsx
  19. 100 0
      apps/insights/src/components/LayoutTransition/index.tsx
  20. 80 27
      apps/insights/src/components/LivePrices/index.tsx
  21. 1 0
      apps/insights/src/components/PriceFeed/chart.tsx
  22. 63 0
      apps/insights/src/components/PriceFeed/layout.module.scss
  23. 193 0
      apps/insights/src/components/PriceFeed/layout.tsx
  24. 1 0
      apps/insights/src/components/PriceFeed/price-components.tsx
  25. 12 0
      apps/insights/src/components/PriceFeed/reference-data.module.scss
  26. 124 0
      apps/insights/src/components/PriceFeed/reference-data.tsx
  27. 86 0
      apps/insights/src/components/PriceFeed/tabs.tsx
  28. 0 3
      apps/insights/src/components/PriceFeedTag/icons.ts
  29. 70 0
      apps/insights/src/components/PriceFeedTag/index.module.scss
  30. 95 0
      apps/insights/src/components/PriceFeedTag/index.tsx
  31. 17 10
      apps/insights/src/components/PriceFeeds/coming-soon-list.module.scss
  32. 2 2
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  33. 9 87
      apps/insights/src/components/PriceFeeds/index.module.scss
  34. 54 137
      apps/insights/src/components/PriceFeeds/index.tsx
  35. 41 0
      apps/insights/src/components/PriceFeeds/layout.tsx
  36. 8 10
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  37. 9 7
      apps/insights/src/components/Publishers/index.module.scss
  38. 29 19
      apps/insights/src/components/Publishers/index.tsx
  39. 6 5
      apps/insights/src/components/Publishers/publishers-card.tsx
  40. 2 12
      apps/insights/src/components/Root/header.tsx
  41. 1 0
      apps/insights/src/components/Root/index.module.scss
  42. 1 2
      apps/insights/src/components/Root/index.tsx
  43. 0 64
      apps/insights/src/components/Root/tab-panel.tsx
  44. 72 8
      apps/insights/src/components/Root/tabs.tsx
  45. 15 17
      apps/insights/src/components/Root/theme-switch.tsx
  46. 1 7
      apps/insights/src/static-data/price-feeds.tsx
  47. 1 1
      apps/staking/next-env.d.ts
  48. 2 1
      governance/xc_admin/packages/xc_admin_frontend/components/InstructionViews/WormholeInstructionView.tsx
  49. 8 2
      governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposal.tsx
  50. 1 1
      governance/xc_admin/packages/xc_admin_frontend/next-env.d.ts
  51. 40 0
      packages/component-library/src/Breadcrumbs/index.module.scss
  52. 32 0
      packages/component-library/src/Breadcrumbs/index.stories.tsx
  53. 72 0
      packages/component-library/src/Breadcrumbs/index.tsx
  54. 8 1
      packages/component-library/src/Drawer/index.module.scss
  55. 3 1
      packages/component-library/src/Drawer/index.tsx
  56. 8 0
      packages/component-library/src/Link/index.module.scss
  57. 7 0
      packages/component-library/src/Link/index.stories.tsx
  58. 13 3
      packages/component-library/src/Link/index.tsx
  59. 27 3
      packages/component-library/src/MainNavTabs/index.module.scss
  60. 32 0
      packages/component-library/src/MainNavTabs/index.stories.tsx
  61. 53 0
      packages/component-library/src/MainNavTabs/index.tsx
  62. 73 33
      packages/component-library/src/Table/index.module.scss
  63. 17 14
      packages/component-library/src/Table/index.tsx
  64. 35 0
      packages/component-library/src/Tabs/index.module.scss
  65. 7 7
      packages/component-library/src/Tabs/index.stories.tsx
  66. 24 19
      packages/component-library/src/Tabs/index.tsx
  67. 12 0
      packages/component-library/src/UnstyledBreadcrumbs/index.tsx
  68. 2 0
      packages/component-library/src/theme.scss
  69. 158 196
      pnpm-lock.yaml
  70. 5 5
      pnpm-workspace.yaml

+ 17 - 0
apps/api-reference/jsx.d.ts

@@ -0,0 +1,17 @@
+/**
+ * This file only exists because in react 19, the JSX namespace was moved under
+ * the React export.  However, some libraries (e.g. react-markdown) still have
+ * some things typed as `JSX.<Something>`.  Until those libraries update to
+ * import the namespace correctly, we'll need this declaration file in place to
+ * expose JSX via the old global location.
+ */
+
+import type { JSX as Jsx } from "react/jsx-runtime";
+
+declare global {
+  namespace JSX {
+    type ElementClass = Jsx.ElementClass;
+    type Element = Jsx.Element;
+    type IntrinsicElements = Jsx.IntrinsicElements;
+  }
+}

+ 1 - 1
apps/api-reference/next-env.d.ts

@@ -2,4 +2,4 @@
 /// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 0 - 3
apps/api-reference/src/markdown-components.tsx

@@ -31,9 +31,6 @@ export const MARKDOWN_COMPONENTS = {
         </Code>
       );
     } else {
-      // @ts-expect-error react-markdown doesn't officially support react 19
-      // yet; there's no issues here in practice but the types don't currently
-      // unify
       return <pre {...props} />;
     }
   },

+ 1 - 1
apps/insights/next-env.d.ts

@@ -2,4 +2,4 @@
 /// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 1 - 1
apps/insights/package.json

@@ -35,7 +35,7 @@
     "clsx": "catalog:",
     "cryptocurrency-icons": "catalog:",
     "dnum": "catalog:",
-    "framer-motion": "catalog:",
+    "motion": "catalog:",
     "next": "catalog:",
     "next-themes": "catalog:",
     "nuqs": "catalog:",

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

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

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

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

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

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

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

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

+ 6 - 0
apps/insights/src/app/price-feeds/page.ts

@@ -1 +1,7 @@
+import type { Metadata } from "next";
+
 export { PriceFeeds as default } from "../../components/PriceFeeds";
+
+export const metadata: Metadata = {
+  title: "Price Feeds",
+};

+ 6 - 0
apps/insights/src/app/publishers/page.ts

@@ -1 +1,7 @@
+import type { Metadata } from "next";
+
 export { Publishers as default } from "../../components/Publishers";
+
+export const metadata: Metadata = {
+  title: "Publishers",
+};

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

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

+ 0 - 1
apps/insights/src/components/PriceFeeds/change-percent.module.scss → apps/insights/src/components/ChangePercent/index.module.scss

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

+ 43 - 26
apps/insights/src/components/PriceFeeds/change-percent.tsx → apps/insights/src/components/ChangePercent/index.tsx

@@ -2,11 +2,12 @@
 
 import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import clsx from "clsx";
 import { type ComponentProps, createContext, use } from "react";
 import { useNumberFormatter } from "react-aria";
 import { z } from "zod";
 
-import styles from "./change-percent.module.scss";
+import styles from "./index.module.scss";
 import { StateType, useData } from "../../use-data";
 import { useLivePrice } from "../LivePrices";
 
@@ -18,20 +19,17 @@ const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
 const CHANGE_PERCENT_SKELETON_WIDTH = 15;
 
 type Props = Omit<ComponentProps<typeof YesterdaysPricesContext>, "value"> & {
-  symbolsToFeedKeys: Record<string, string>;
+  feeds: (Feed & { symbol: string })[];
 };
 
 const YesterdaysPricesContext = createContext<
   undefined | ReturnType<typeof useData<Map<string, number>>>
 >(undefined);
 
-export const YesterdaysPricesProvider = ({
-  symbolsToFeedKeys,
-  ...props
-}: Props) => {
+export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => {
   const state = useData(
-    ["yesterdaysPrices", Object.values(symbolsToFeedKeys)],
-    () => getYesterdaysPrices(symbolsToFeedKeys),
+    ["yesterdaysPrices", feeds.map((feed) => feed.symbol)],
+    () => getYesterdaysPrices(feeds),
     {
       refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
     },
@@ -41,17 +39,21 @@ export const YesterdaysPricesProvider = ({
 };
 
 const getYesterdaysPrices = async (
-  symbolsToFeedKeys: Record<string, string>,
+  feeds: (Feed & { symbol: string })[],
 ): Promise<Map<string, number>> => {
   const url = new URL("/yesterdays-prices", window.location.origin);
-  for (const symbol of Object.keys(symbolsToFeedKeys)) {
-    url.searchParams.append("symbols", symbol);
+  for (const feed of feeds) {
+    url.searchParams.append("symbols", feed.symbol);
   }
   const response = await fetch(url);
   const data: unknown = await response.json();
   return new Map(
     Object.entries(yesterdaysPricesSchema.parse(data)).map(
-      ([symbol, value]) => [symbolsToFeedKeys[symbol] ?? "", value],
+      ([symbol, value]) => [
+        feeds.find((feed) => feed.symbol === symbol)?.product.price_account ??
+          "",
+        value,
+      ],
     ),
   );
 };
@@ -69,10 +71,17 @@ const useYesterdaysPrices = () => {
 };
 
 type ChangePercentProps = {
-  feedKey: string;
+  className?: string | undefined;
+  feed: Feed;
 };
 
-export const ChangePercent = ({ feedKey }: ChangePercentProps) => {
+type Feed = {
+  product: {
+    price_account: string;
+  };
+};
+
+export const ChangePercent = ({ feed, className }: ChangePercentProps) => {
   const yesterdaysPriceState = useYesterdaysPrices();
 
   switch (yesterdaysPriceState.type) {
@@ -85,52 +94,60 @@ export const ChangePercent = ({ feedKey }: ChangePercentProps) => {
     case StateType.NotLoaded: {
       return (
         <Skeleton
-          className={styles.changePercent}
+          className={clsx(styles.changePercent, className)}
           width={CHANGE_PERCENT_SKELETON_WIDTH}
         />
       );
     }
 
     case StateType.Loaded: {
-      const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
+      const yesterdaysPrice = yesterdaysPriceState.data.get(
+        feed.product.price_account,
+      );
       // eslint-disable-next-line unicorn/no-null
       return yesterdaysPrice === undefined ? null : (
-        <ChangePercentLoaded priorPrice={yesterdaysPrice} feedKey={feedKey} />
+        <ChangePercentLoaded
+          className={clsx(styles.changePercent, className)}
+          priorPrice={yesterdaysPrice}
+          feed={feed}
+        />
       );
     }
   }
 };
 
 type ChangePercentLoadedProps = {
+  className?: string | undefined;
   priorPrice: number;
-  feedKey: string;
+  feed: Feed;
 };
 
 const ChangePercentLoaded = ({
+  className,
   priorPrice,
-  feedKey,
+  feed,
 }: ChangePercentLoadedProps) => {
-  const currentPrice = useLivePrice(feedKey);
+  const currentPrice = useLivePrice(feed);
 
   return currentPrice === undefined ? (
-    <Skeleton
-      className={styles.changePercent}
-      width={CHANGE_PERCENT_SKELETON_WIDTH}
-    />
+    <Skeleton className={className} width={CHANGE_PERCENT_SKELETON_WIDTH} />
   ) : (
     <PriceDifference
-      currentPrice={currentPrice.price}
+      className={className}
+      currentPrice={currentPrice.aggregate.price}
       priorPrice={priorPrice}
     />
   );
 };
 
 type PriceDifferenceProps = {
+  className?: string | undefined;
   currentPrice: number;
   priorPrice: number;
 };
 
 const PriceDifference = ({
+  className,
   currentPrice,
   priorPrice,
 }: PriceDifferenceProps) => {
@@ -138,7 +155,7 @@ const PriceDifference = ({
   const direction = getDirection(currentPrice, priorPrice);
 
   return (
-    <span data-direction={direction} className={styles.changePercent}>
+    <span data-direction={direction} className={className}>
       <CaretUp weight="fill" className={styles.caret} />
       {numberFormatter.format(
         (100 * Math.abs(currentPrice - priorPrice)) / currentPrice,

+ 6 - 32
apps/insights/src/components/CopyButton/index.module.scss

@@ -1,55 +1,29 @@
 @use "@pythnetwork/component-library/theme";
 
 .copyButton {
-  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) theme.spacing(1);
-  background: none;
-  cursor: pointer;
-  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;
-    top: 0.125em;
-    margin-left: theme.spacing(1);
-    display: inline-block;
 
     .copyIcon {
       opacity: 0.5;
       transition: opacity 100ms linear;
-      width: 1em;
-      height: 1em;
+      position: absolute;
+      inset: 0;
+      width: 100%;
+      height: 100%;
     }
 
     .checkIcon {
       position: absolute;
       inset: 0;
+      width: 100%;
+      height: 100%;
       color: theme.color("states", "success", "normal");
       opacity: 0;
       transition: opacity 100ms linear;
     }
   }
 
-  &[data-hovered] {
-    background-color: theme.color("button", "outline", "background", "hover");
-  }
-
-  &[data-pressed] {
-    background-color: theme.color("button", "outline", "background", "active");
-  }
-
-  &[data-focus-visible] {
-    border-color: theme.color("focus");
-    outline-color: theme.color("focus-dim");
-  }
-
   &[data-is-copied] .iconContainer {
     .copyIcon {
       opacity: 0;

+ 18 - 21
apps/insights/src/components/CopyButton/index.tsx

@@ -3,22 +3,23 @@
 import { Check } from "@phosphor-icons/react/dist/ssr/Check";
 import { Copy } from "@phosphor-icons/react/dist/ssr/Copy";
 import { useLogger } from "@pythnetwork/app-logger";
-import { UnstyledButton } from "@pythnetwork/component-library/UnstyledButton";
+import { Button } from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
 import { type ComponentProps, useCallback, useEffect, useState } from "react";
 
 import styles from "./index.module.scss";
 
-type CopyButtonProps = ComponentProps<typeof UnstyledButton> & {
+type OwnProps = {
   text: string;
 };
 
-export const CopyButton = ({
-  text,
-  children,
-  className,
-  ...props
-}: CopyButtonProps) => {
+type Props = Omit<
+  ComponentProps<typeof Button>,
+  keyof OwnProps | "onPress" | "afterIcon"
+> &
+  OwnProps;
+
+export const CopyButton = ({ text, children, className, ...props }: Props) => {
   const [isCopied, setIsCopied] = useState(false);
   const logger = useLogger();
   const copy = useCallback(() => {
@@ -52,23 +53,19 @@ export const CopyButton = ({
   }, [isCopied]);
 
   return (
-    <UnstyledButton
+    <Button
       onPress={copy}
       className={clsx(styles.copyButton, className)}
+      afterIcon={({ className, ...props }) => (
+        <div className={clsx(styles.iconContainer, className)} {...props}>
+          <Copy className={styles.copyIcon} />
+          <Check className={styles.checkIcon} />
+        </div>
+      )}
       {...(isCopied && { "data-is-copied": true })}
       {...props}
     >
-      {(...args) => (
-        <>
-          <span>
-            {typeof children === "function" ? children(...args) : children}
-          </span>
-          <span className={styles.iconContainer}>
-            <Copy className={styles.copyIcon} />
-            <Check className={styles.checkIcon} />
-          </span>
-        </>
-      )}
-    </UnstyledButton>
+      {children}
+    </Button>
   );
 };

+ 45 - 0
apps/insights/src/components/FeedKey/index.tsx

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

+ 4 - 4
apps/insights/src/components/FormattedTokens/index.tsx

@@ -8,18 +8,18 @@ const DECIMALS = 6;
 
 type Props = {
   mode?: "compact" | "wholePart" | "full";
-  children: bigint;
+  tokens: bigint;
 };
 
-export const FormattedTokens = ({ children, mode = "compact" }: Props) => {
+export const FormattedTokens = ({ tokens, mode = "compact" }: Props) => {
   const { locale } = useLocale();
   const value = useMemo(
     () =>
-      dnum.format([children, DECIMALS], {
+      dnum.format([tokens, DECIMALS], {
         compact: mode === "compact",
         locale,
       }),
-    [children, locale, mode],
+    [tokens, locale, mode],
   );
 
   return mode === "wholePart" ? value.split(".")[0] : value;

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

@@ -0,0 +1,100 @@
+"use client";
+
+import {
+  type TargetAndTransition,
+  type Target,
+  AnimatePresence,
+  motion,
+} from "motion/react";
+import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
+import { useSelectedLayoutSegment } from "next/navigation";
+import {
+  type ReactNode,
+  type ComponentProps,
+  useContext,
+  useEffect,
+  useRef,
+} from "react";
+
+type OwnProps = {
+  children: ReactNode;
+  variants?: Record<
+    string,
+    | TargetAndTransition
+    | ((
+        custom: VariantArg,
+        current: Target,
+        velocity: Target,
+      ) => TargetAndTransition | string)
+  >;
+};
+
+export type VariantArg = {
+  segment: ReturnType<typeof useSelectedLayoutSegment>;
+  prevSegment: ReturnType<typeof useSelectedLayoutSegment>;
+};
+
+type Props = Omit<ComponentProps<typeof motion.div>, keyof OwnProps> & OwnProps;
+
+export const LayoutTransition = ({ children, ...props }: Props) => {
+  const segment = useSelectedLayoutSegment();
+  const prevSegment =
+    useRef<ReturnType<typeof useSelectedLayoutSegment>>(segment);
+  const nextSegment =
+    useRef<ReturnType<typeof useSelectedLayoutSegment>>(segment);
+
+  useEffect(() => {
+    nextSegment.current = segment;
+  }, [segment]);
+
+  const updatePrevSegment = () => {
+    prevSegment.current = nextSegment.current;
+  };
+
+  return (
+    <AnimatePresence
+      mode="wait"
+      initial={false}
+      onExitComplete={updatePrevSegment}
+      custom={{ segment, prevSegment: prevSegment.current }}
+    >
+      <motion.div
+        key={segment}
+        custom={{ segment, prevSegment: prevSegment.current }}
+        {...props}
+      >
+        <FrozenRouter>{children}</FrozenRouter>
+      </motion.div>
+    </AnimatePresence>
+  );
+};
+
+const FrozenRouter = ({ children }: { children: ReactNode }) => {
+  const context = useContext(LayoutRouterContext);
+  // eslint-disable-next-line unicorn/no-null
+  const prevContext = usePreviousValue(context) ?? null;
+
+  const segment = useSelectedLayoutSegment();
+  const prevSegment = usePreviousValue(segment);
+
+  const changed = segment !== prevSegment && prevSegment !== undefined;
+
+  return (
+    <LayoutRouterContext.Provider value={changed ? prevContext : context}>
+      {children}
+    </LayoutRouterContext.Provider>
+  );
+};
+
+const usePreviousValue = <T,>(value: T): T | undefined => {
+  const prevValue = useRef<T>(undefined);
+
+  useEffect(() => {
+    prevValue.current = value;
+    return () => {
+      prevValue.current = undefined;
+    };
+  });
+
+  return prevValue.current;
+};

+ 80 - 27
apps/insights/src/components/LivePrices/index.tsx

@@ -2,18 +2,21 @@
 
 import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
 import { useLogger } from "@pythnetwork/app-logger";
+import type { PriceData } from "@pythnetwork/client";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { useMap } from "@react-hookz/web";
 import { PublicKey } from "@solana/web3.js";
 import {
   type ComponentProps,
+  type ReactNode,
   use,
   createContext,
   useEffect,
   useCallback,
   useState,
+  useMemo,
 } from "react";
-import { useNumberFormatter } from "react-aria";
+import { useNumberFormatter, useDateFormatter } from "react-aria";
 
 import styles from "./index.module.scss";
 import { client, subscribe } from "../../services/pyth";
@@ -24,10 +27,8 @@ const LivePricesContext = createContext<
   ReturnType<typeof usePriceData> | undefined
 >(undefined);
 
-type Price = {
-  price: number;
+type Price = PriceData & {
   direction: ChangeDirection;
-  confidence: number;
 };
 
 type ChangeDirection = "up" | "down" | "flat";
@@ -43,46 +44,103 @@ export const LivePricesProvider = ({ ...props }: LivePricesProviderProps) => {
   return <LivePricesContext value={priceData} {...props} />;
 };
 
-export const useLivePrice = (account: string) => {
+type Feed = {
+  product: {
+    price_account: string;
+  };
+};
+
+export const useLivePrice = (feed: Feed) => {
+  const { price_account } = feed.product;
   const { priceData, addSubscription, removeSubscription } = useLivePrices();
 
   useEffect(() => {
-    addSubscription(account);
+    addSubscription(price_account);
     return () => {
-      removeSubscription(account);
+      removeSubscription(price_account);
     };
-  }, [addSubscription, removeSubscription, account]);
+  }, [addSubscription, removeSubscription, price_account]);
 
-  return priceData.get(account);
+  return priceData.get(price_account);
 };
 
-export const LivePrice = ({ account }: { account: string }) => {
+export const LivePrice = ({ feed }: { feed: Feed }) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(account);
+  const price = useLivePrice(feed);
 
   return price === undefined ? (
     <Skeleton width={SKELETON_WIDTH} />
   ) : (
     <span className={styles.price} data-direction={price.direction}>
-      {numberFormatter.format(price.price)}
+      {numberFormatter.format(price.aggregate.price)}
     </span>
   );
 };
 
-export const LiveConfidence = ({ account }: { account: string }) => {
+export const LiveConfidence = ({ feed }: { feed: Feed }) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(account);
+  const price = useLivePrice(feed);
 
-  return price === undefined ? (
-    <Skeleton width={SKELETON_WIDTH} />
-  ) : (
+  return (
     <span className={styles.confidence}>
       <PlusMinus className={styles.plusMinus} />
-      <span>{numberFormatter.format(price.confidence)}</span>
+      {price === undefined ? (
+        <Skeleton width={SKELETON_WIDTH} />
+      ) : (
+        <span>{numberFormatter.format(price.aggregate.confidence)}</span>
+      )}
     </span>
   );
 };
 
+export const LiveLastUpdated = ({ feed }: { feed: Feed }) => {
+  const price = useLivePrice(feed);
+  const formatterWithDate = useDateFormatter({
+    dateStyle: "short",
+    timeStyle: "medium",
+  });
+  const formatterWithoutDate = useDateFormatter({
+    timeStyle: "medium",
+  });
+  const formattedTimestamp = useMemo(() => {
+    if (price) {
+      const timestamp = new Date(Number(price.timestamp * 1000n));
+      return isToday(timestamp)
+        ? formatterWithoutDate.format(timestamp)
+        : formatterWithDate.format(timestamp);
+    } else {
+      return;
+    }
+  }, [price, formatterWithDate, formatterWithoutDate]);
+
+  return formattedTimestamp ?? <Skeleton width={SKELETON_WIDTH} />;
+};
+
+type LiveValueProps<T extends keyof PriceData> = {
+  field: T;
+  feed: Feed & {
+    price: Record<T, ReactNode>;
+  };
+};
+
+export const LiveValue = <T extends keyof PriceData>({
+  feed,
+  field,
+}: LiveValueProps<T>) => {
+  const price = useLivePrice(feed);
+
+  return price?.[field]?.toString() ?? feed.price[field];
+};
+
+const isToday = (date: Date) => {
+  const now = new Date();
+  return (
+    date.getFullYear() === now.getFullYear() &&
+    date.getMonth() === now.getMonth() &&
+    date.getDate() === now.getDate()
+  );
+};
+
 const usePriceData = () => {
   const feedSubscriptions = useMap<string, number>([]);
   const [feedKeys, setFeedKeys] = useState<string[]>([]);
@@ -104,11 +162,7 @@ const usePriceData = () => {
           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,
-              });
+              priceData.set(key, { ...price, direction: "flat" });
             }
           }
         })
@@ -120,13 +174,12 @@ const usePriceData = () => {
     // Then, we create a subscription to update prices live.
     const connection = subscribe(
       feedKeys.map((key) => new PublicKey(key)),
-      ({ price_account }, { aggregate }) => {
+      ({ price_account }, price) => {
         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,
+            ...price,
+            direction: getChangeDirection(prevPrice, price.aggregate.price),
           });
         }
       },

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

@@ -0,0 +1 @@
+export const Chart = () => <h1>Chart</h1>;

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

@@ -0,0 +1,63 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceFeedLayout {
+  .header {
+    @include theme.max-width;
+
+    margin: theme.spacing(6) auto;
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(6);
+
+    .headerRow,
+    .rightGroup,
+    .stats {
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+    }
+
+    .headerRow {
+      justify-content: space-between;
+    }
+
+    .rightGroup {
+      gap: theme.spacing(2);
+    }
+
+    .stats {
+      gap: theme.spacing(6);
+
+      & > * {
+        flex: 1 1 0px;
+        width: 0;
+      }
+
+      .confidenceExplainButton {
+        margin-top: -#{theme.button-padding("xs", false)};
+        margin-right: -#{theme.button-padding("xs", false)};
+      }
+    }
+  }
+
+  .priceComponentsTabLabel {
+    display: inline-flex;
+    flex-flow: row nowrap;
+    gap: theme.spacing(2);
+    align-items: center;
+  }
+
+  .body {
+    @include theme.max-width;
+
+    padding-top: theme.spacing(6);
+  }
+}
+
+.confidenceDescription {
+  margin: 0;
+
+  b {
+    font-weight: theme.font-weight("semibold");
+  }
+}

+ 193 - 0
apps/insights/src/components/PriceFeed/layout.tsx

@@ -0,0 +1,193 @@
+import { Info } from "@phosphor-icons/react/dist/ssr/Info";
+import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
+import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
+import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
+import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
+import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import type { ReactNode } from "react";
+import { z } from "zod";
+
+import styles from "./layout.module.scss";
+import { ReferenceData } from "./reference-data";
+import { TabPanel, TabRoot, Tabs } from "./tabs";
+import { client } from "../../services/pyth";
+import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
+import { FeedKey } from "../FeedKey";
+import { LivePrice, LiveConfidence, LiveLastUpdated } from "../LivePrices";
+import { NotFound } from "../NotFound";
+import { PriceFeedTag } from "../PriceFeedTag";
+
+type Props = {
+  children: ReactNode;
+  params: Promise<{
+    slug: string;
+  }>;
+};
+
+export const PriceFeedLayout = async ({ children, params }: Props) => {
+  const { slug } = await params;
+  const feed = await getPriceFeed(decodeURIComponent(slug));
+
+  return feed ? (
+    <div className={styles.priceFeedLayout}>
+      <section className={styles.header}>
+        <div className={styles.headerRow}>
+          <Breadcrumbs
+            label="Breadcrumbs"
+            items={[
+              { href: "/", label: "Home" },
+              { href: "/price-feeds", label: "Price Feeds" },
+              { label: feed.product.display_symbol },
+            ]}
+          />
+          <div>
+            <Badge variant="neutral" style="outline" size="md">
+              {feed.product.asset_type.toUpperCase()}
+            </Badge>
+          </div>
+        </div>
+        <div className={styles.headerRow}>
+          <PriceFeedTag feed={feed} />
+          <div className={styles.rightGroup}>
+            <FeedKey
+              variant="ghost"
+              size="sm"
+              className={styles.feedKey ?? ""}
+              feed={feed}
+            />
+            <DrawerTrigger>
+              <Button variant="outline" size="sm" beforeIcon={ListDashes}>
+                Reference Data
+              </Button>
+              <Drawer title="Reference Data">
+                <ReferenceData feed={feed} />
+              </Drawer>
+            </DrawerTrigger>
+          </div>
+        </div>
+        <section className={styles.stats}>
+          <StatCard
+            variant="primary"
+            header="Aggregated Price"
+            stat={<LivePrice feed={feed} />}
+          />
+          <StatCard
+            header="Confidence"
+            stat={<LiveConfidence feed={feed} />}
+            corner={
+              <AlertTrigger>
+                <Button
+                  variant="ghost"
+                  size="xs"
+                  beforeIcon={(props) => <Info weight="fill" {...props} />}
+                  rounded
+                  hideText
+                  className={styles.confidenceExplainButton ?? ""}
+                >
+                  Explain Confidence
+                </Button>
+                <Alert title="Confidence" icon={<Lightbulb />}>
+                  <p className={styles.confidenceDescription}>
+                    <b>Confidence</b> is how far from the aggregate price Pyth
+                    believes the true price might be. It reflects a combination
+                    of the confidence of individual quoters and how well
+                    individual quoters agree with each other.
+                  </p>
+                  <ButtonLink
+                    size="xs"
+                    variant="solid"
+                    href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
+                    target="_blank"
+                  >
+                    Learn more
+                  </ButtonLink>
+                </Alert>
+              </AlertTrigger>
+            }
+          />
+          <StatCard
+            header="1-Day Price Change"
+            stat={
+              <YesterdaysPricesProvider feeds={[feed]}>
+                <ChangePercent feed={feed} />
+              </YesterdaysPricesProvider>
+            }
+          />
+          <StatCard
+            header="Last Updated"
+            stat={<LiveLastUpdated feed={feed} />}
+          />
+        </section>
+      </section>
+      <TabRoot>
+        <Tabs
+          label="Price Feed Navigation"
+          slug={slug}
+          items={[
+            { segment: undefined, children: "Chart" },
+            {
+              segment: "price-components",
+              children: (
+                <div className={styles.priceComponentsTabLabel}>
+                  <span>Price Components</span>
+                  <Badge size="xs" style="filled" variant="neutral">
+                    {feed.price.numComponentPrices}
+                  </Badge>
+                </div>
+              ),
+            },
+          ]}
+        />
+        <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
+      </TabRoot>
+    </div>
+  ) : (
+    <NotFound />
+  );
+};
+
+const getPriceFeed = async (symbol: string) => {
+  const data = await client.getData();
+  const priceFeeds = priceFeedsSchema.parse(
+    data.symbols.map((symbol) => ({
+      symbol,
+      product: data.productFromSymbol.get(symbol),
+      price: data.productPrice.get(symbol),
+    })),
+  );
+  return priceFeeds.find((feed) => feed.symbol === symbol);
+};
+
+const priceFeedsSchema = z.array(
+  z.object({
+    symbol: z.string(),
+    product: z.object({
+      display_symbol: z.string(),
+      asset_type: z.string(),
+      description: z.string(),
+      price_account: z.string(),
+      base: z.string().optional(),
+      country: z.string().optional(),
+      quote_currency: z.string().optional(),
+      tenor: z.string().optional(),
+      cms_symbol: z.string().optional(),
+      cqs_symbol: z.string().optional(),
+      nasdaq_symbol: z.string().optional(),
+      generic_symbol: z.string().optional(),
+      weekly_schedule: z.string().optional(),
+      schedule: z.string().optional(),
+      contract_id: z.string().optional(),
+    }),
+    price: z.object({
+      exponent: z.number(),
+      numComponentPrices: z.number(),
+      numQuoters: z.number(),
+      minPublishers: z.number(),
+      lastSlot: z.bigint(),
+      validSlot: z.bigint(),
+    }),
+  }),
+);

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

@@ -0,0 +1 @@
+export const PriceComponents = () => <h1>Price Components</h1>;

+ 12 - 0
apps/insights/src/components/PriceFeed/reference-data.module.scss

@@ -0,0 +1,12 @@
+@use "@pythnetwork/component-library/theme";
+
+.referenceData {
+  .field {
+    color: theme.color("muted");
+    font-weight: theme.font-weight("normal");
+  }
+
+  .value {
+    color: theme.color("paragraph");
+  }
+}

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

@@ -0,0 +1,124 @@
+"use client";
+
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Table } from "@pythnetwork/component-library/Table";
+import { useMemo } from "react";
+import { useCollator } from "react-aria";
+
+import styles from "./reference-data.module.scss";
+import { LiveValue } from "../LivePrices";
+
+type Props = {
+  feed: {
+    symbol: string;
+    product: {
+      display_symbol: string;
+      asset_type: string;
+      description: string;
+      price_account: string;
+      base?: string | undefined;
+      country?: string | undefined;
+      quote_currency?: string | undefined;
+      tenor?: string | undefined;
+      cms_symbol?: string | undefined;
+      cqs_symbol?: string | undefined;
+      nasdaq_symbol?: string | undefined;
+      generic_symbol?: string | undefined;
+      weekly_schedule?: string | undefined;
+      schedule?: string | undefined;
+      contract_id?: string | undefined;
+    };
+    price: {
+      exponent: number;
+      numComponentPrices: number;
+      numQuoters: number;
+      minPublishers: number;
+      lastSlot: bigint;
+      validSlot: bigint;
+    };
+  };
+};
+
+export const ReferenceData = ({ feed }: Props) => {
+  const collator = useCollator();
+
+  const rows = useMemo(
+    () =>
+      [
+        ...Object.entries({
+          "Asset Type": (
+            <Badge variant="neutral" style="outline" size="xs">
+              {feed.product.asset_type.toUpperCase()}
+            </Badge>
+          ),
+          Base: feed.product.base,
+          Description: feed.product.description,
+          Symbol: feed.symbol,
+          Country: feed.product.country,
+          "Quote Currency": feed.product.quote_currency,
+          Tenor: feed.product.tenor,
+          "CMS Symbol": feed.product.cms_symbol,
+          "CQS Symbol": feed.product.cqs_symbol,
+          "NASDAQ Symbol": feed.product.nasdaq_symbol,
+          "Generic Symbol": feed.product.generic_symbol,
+          "Weekly Schedule": feed.product.weekly_schedule,
+          Schedule: feed.product.schedule,
+          "Contract ID": feed.product.contract_id,
+          "Display Symbol": feed.product.display_symbol,
+        }),
+        ...Object.entries({
+          Exponent: "exponent",
+          "Number of Price Components": "numComponentPrices",
+          "Number of Price Quoters": "numQuoters",
+          "Minimum Number of Publishers": "minPublishers",
+          "Last Slot": "lastSlot",
+          "Valid Slot": "validSlot",
+        } as const).map(
+          ([key, value]) =>
+            [
+              key,
+              <span key={key} className={styles.value}>
+                <LiveValue feed={feed} field={value} />
+              </span>,
+            ] as const,
+        ),
+      ]
+        .map(([field, value]) =>
+          value === undefined ? undefined : ([field, value] as const),
+        )
+        .filter((entry) => entry !== undefined)
+        .sort(([a], [b]) => collator.compare(a, b))
+        .map(([field, value]) => ({
+          id: field,
+          data: {
+            field: <span className={styles.field}>{field}</span>,
+            value:
+              typeof value === "string" ? (
+                <span className={styles.value}>{value}</span>
+              ) : (
+                value
+              ),
+          },
+        })),
+    [collator, feed],
+  );
+
+  return (
+    <Table
+      label="Reference Data"
+      fill
+      className={styles.referenceData}
+      columns={[
+        {
+          id: "field",
+          name: "Field",
+          alignment: "left",
+          isRowHeader: true,
+          sticky: true,
+        },
+        { id: "value", name: "Value", fill: true, alignment: "left" },
+      ]}
+      rows={rows}
+    />
+  );
+};

+ 86 - 0
apps/insights/src/components/PriceFeed/tabs.tsx

@@ -0,0 +1,86 @@
+"use client";
+
+import { Tabs as TabsComponent } from "@pythnetwork/component-library/Tabs";
+import {
+  UnstyledTabPanel,
+  UnstyledTabs,
+} from "@pythnetwork/component-library/UnstyledTabs";
+import { useSelectedLayoutSegment, usePathname } from "next/navigation";
+import { useMemo, type ComponentProps } from "react";
+
+import { LayoutTransition } from "../LayoutTransition";
+
+export const TabRoot = (
+  props: Omit<ComponentProps<typeof UnstyledTabs>, "selectedKey">,
+) => {
+  const tabId = useSelectedLayoutSegment() ?? "";
+
+  return <UnstyledTabs selectedKey={tabId} {...props} />;
+};
+
+type TabsProps = Omit<
+  ComponentProps<typeof TabsComponent>,
+  "pathname" | "items"
+> & {
+  slug: string;
+  items: (Omit<
+    ComponentProps<typeof TabsComponent>["items"],
+    "href" | "id"
+  >[number] & {
+    segment: string | undefined;
+  })[];
+};
+
+export const Tabs = ({ slug, items, ...props }: TabsProps) => {
+  const pathname = usePathname();
+  const mappedItems = useMemo(() => {
+    const prefix = `/price-feeds/${slug}`;
+    return items.map((item) => ({
+      ...item,
+      id: item.segment ?? "",
+      href: item.segment ? `${prefix}/${item.segment}` : prefix,
+    }));
+  }, [items, slug]);
+
+  return <TabsComponent pathname={pathname} items={mappedItems} {...props} />;
+};
+
+export const TabPanel = ({
+  children,
+  ...props
+}: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">) => {
+  const tabId = useSelectedLayoutSegment() ?? "";
+
+  return (
+    <UnstyledTabPanel key="tabpanel" id={tabId} {...props}>
+      {(args) => (
+        <LayoutTransition
+          variants={{
+            initial: ({ segment }) => ({
+              opacity: 0,
+              x: segment === null ? "-2%" : "2%",
+            }),
+            exit: ({ segment }) => ({
+              opacity: 0,
+              x: segment === null ? "2%" : "-2%",
+              transition: {
+                x: { type: "spring", bounce: 0 },
+              },
+            }),
+          }}
+          initial="initial"
+          animate={{
+            opacity: 1,
+            x: 0,
+            transition: {
+              x: { type: "spring", bounce: 0 },
+            },
+          }}
+          exit="exit"
+        >
+          {typeof children === "function" ? children(args) : children}
+        </LayoutTransition>
+      )}
+    </UnstyledTabPanel>
+  );
+};

+ 0 - 3
apps/insights/src/icons.tsx → apps/insights/src/components/PriceFeedTag/icons.ts

@@ -969,6 +969,3 @@ export const icons = {
   ZIL: Zil,
   ZRX: Zrx,
 };
-
-export const getIcon = (symbol: string) =>
-  symbol in icons ? icons[symbol as keyof typeof icons] : undefined;

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

@@ -0,0 +1,70 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceFeedTag {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(3);
+  align-items: center;
+  width: 100%;
+
+  .icon {
+    flex: none;
+    width: theme.spacing(10);
+    height: theme.spacing(10);
+  }
+
+  .nameAndDescription {
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(1);
+    flex-grow: 1;
+    flex-basis: 0;
+    white-space: nowrap;
+    overflow: hidden;
+
+    .name {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+      gap: theme.spacing(1);
+      color: theme.color("heading");
+
+      .firstPart {
+        font-weight: theme.font-weight("medium");
+      }
+
+      .divider {
+        font-weight: theme.font-weight("light");
+        color: theme.color("muted");
+      }
+
+      .part {
+        opacity: 0.6;
+      }
+    }
+
+    .description {
+      font-size: theme.font-size("xs");
+      font-weight: theme.font-weight("medium");
+      line-height: theme.spacing(4);
+      color: theme.color("muted");
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  &[data-compact] {
+    .icon {
+      width: theme.spacing(6);
+      height: theme.spacing(6);
+    }
+  }
+
+  &[data-loading] {
+    .icon {
+      border-radius: theme.border-radius("full");
+    }
+  }
+}

+ 95 - 0
apps/insights/src/components/PriceFeedTag/index.tsx

@@ -0,0 +1,95 @@
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import clsx from "clsx";
+import Generic from "cryptocurrency-icons/svg/color/generic.svg";
+import { type ComponentProps, Fragment } from "react";
+
+import { icons } from "./icons";
+import styles from "./index.module.scss";
+
+type OwnProps = {
+  compact?: boolean | undefined;
+  feed?:
+    | {
+        product: {
+          display_symbol: string;
+          description: string;
+        };
+      }
+    | undefined;
+};
+type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
+
+export const PriceFeedTag = ({ feed, className, compact, ...props }: Props) => (
+  <div
+    className={clsx(styles.priceFeedTag, className)}
+    data-compact={compact ? "" : undefined}
+    data-loading={feed === undefined}
+    {...props}
+  >
+    {feed === undefined ? (
+      <Skeleton fill className={styles.icon} />
+    ) : (
+      <FeedIcon className={styles.icon} symbol={feed.product.display_symbol} />
+    )}
+    <div className={styles.nameAndDescription}>
+      {feed === undefined ? (
+        <div className={styles.name}>
+          <Skeleton width={30} />
+        </div>
+      ) : (
+        <FeedName
+          className={styles.name}
+          symbol={feed.product.display_symbol}
+        />
+      )}
+      {!compact && (
+        <div className={styles.description}>
+          {feed === undefined ? (
+            <Skeleton width={50} />
+          ) : (
+            feed.product.description.split("/")[0]
+          )}
+        </div>
+      )}
+    </div>
+  </div>
+);
+
+type OwnFeedNameProps = { symbol: string };
+type FeedNameProps = Omit<ComponentProps<"div">, keyof OwnFeedNameProps> &
+  OwnFeedNameProps;
+
+const FeedName = ({ symbol, className, ...props }: FeedNameProps) => {
+  const [firstPart, ...parts] = symbol.split("/");
+
+  return (
+    <div className={clsx(styles.priceFeedName, className)} {...props}>
+      <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>
+  );
+};
+
+type OwnFeedIconProps = {
+  symbol: string;
+};
+type FeedIconProps = Omit<
+  ComponentProps<typeof Generic>,
+  keyof OwnFeedIconProps | "width" | "height" | "viewBox"
+> &
+  OwnFeedIconProps;
+
+const FeedIcon = ({ symbol, ...props }: FeedIconProps) => {
+  const firstPart = symbol.split("/")[0];
+  const Icon =
+    firstPart && firstPart in icons
+      ? icons[firstPart as keyof typeof icons]
+      : Generic;
+
+  return <Icon width="100%" height="100%" viewBox="0 0 32 32" {...props} />;
+};

+ 17 - 10
apps/insights/src/components/PriceFeeds/coming-soon-list.module.scss

@@ -1,15 +1,22 @@
 @use "@pythnetwork/component-library/theme";
 
-.searchBar {
-  width: 100%;
-  padding: theme.spacing(3);
+.comingSoonList {
   display: flex;
-  flex-flow: row nowrap;
-  gap: theme.spacing(3);
-  flex: none;
-}
+  flex-flow: column nowrap;
+  overflow: hidden;
+  height: 100%;
+
+  .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;
+  .priceFeeds {
+    overflow: auto;
+    flex-grow: 1;
+  }
 }

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

@@ -67,7 +67,7 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
     [filteredFeeds],
   );
   return (
-    <>
+    <div className={styles.comingSoonList}>
       <div className={styles.searchBar}>
         <SearchInput
           size="sm"
@@ -109,6 +109,6 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
         ]}
         rows={rows}
       />
-    </>
+    </div>
   );
 };

+ 9 - 87
apps/insights/src/components/PriceFeeds/index.module.scss

@@ -1,55 +1,5 @@
 @use "@pythnetwork/component-library/theme";
 
-.priceFeedNameAndIcon,
-.priceFeedNameAndDescription {
-  display: flex;
-  flex-flow: row nowrap;
-  gap: theme.spacing(3);
-  align-items: center;
-  width: 100%;
-
-  .priceFeedIcon {
-    flex: none;
-  }
-
-  .priceFeedName {
-    display: flex;
-    flex-flow: row nowrap;
-    align-items: center;
-    gap: theme.spacing(1);
-    color: theme.color("heading");
-
-    .firstPart {
-      font-weight: theme.font-weight("medium");
-    }
-
-    .divider {
-      font-weight: theme.font-weight("light");
-      color: theme.color("muted");
-    }
-
-    .part {
-      opacity: 0.6;
-    }
-  }
-}
-
-.priceFeedNameAndIcon {
-  .priceFeedIcon {
-    width: theme.spacing(6);
-    height: theme.spacing(6);
-
-    &.skeleton {
-      border-radius: theme.border-radius("full");
-    }
-  }
-
-  .priceFeedName {
-    flex-grow: 1;
-    flex-basis: 0;
-  }
-}
-
 .priceFeeds {
   @include theme.max-width;
 
@@ -66,6 +16,10 @@
     flex-flow: column nowrap;
     gap: theme.spacing(6);
 
+    .feedKey {
+      margin: 0 -#{theme.button-padding("xs", true)};
+    }
+
     .featuredFeeds,
     .stats {
       display: flex;
@@ -79,7 +33,7 @@
     }
 
     .stats {
-      gap: theme.spacing(4);
+      gap: theme.spacing(6);
     }
 
     .featuredFeeds {
@@ -93,37 +47,6 @@
         padding: theme.spacing(3);
         gap: theme.spacing(6);
 
-        .priceFeedNameAndDescription {
-          .priceFeedIcon {
-            width: theme.spacing(10);
-            height: theme.spacing(10);
-          }
-
-          .nameAndDescription {
-            display: flex;
-            flex-flow: column nowrap;
-            gap: theme.spacing(1);
-            flex-grow: 1;
-            flex-basis: 0;
-            white-space: nowrap;
-            overflow: hidden;
-
-            .priceFeedName {
-              overflow: hidden;
-              text-overflow: ellipsis;
-            }
-
-            .description {
-              font-size: theme.font-size("xs");
-              font-weight: theme.font-weight("medium");
-              line-height: theme.spacing(4);
-              color: theme.color("muted");
-              overflow: hidden;
-              text-overflow: ellipsis;
-            }
-          }
-        }
-
         .prices {
           display: flex;
           flex-flow: row nowrap;
@@ -133,13 +56,12 @@
           font-weight: theme.font-weight("medium");
           line-height: 1;
           font-size: theme.font-size("base");
+
+          .changePercent {
+            font-size: theme.font-size("sm");
+          }
         }
       }
     }
-
-    .priceFeedId {
-      color: theme.color("link", "normal");
-      font-weight: theme.font-weight("medium");
-    }
   }
 }

+ 54 - 137
apps/insights/src/components/PriceFeeds/index.tsx

@@ -10,24 +10,20 @@ import {
   Card,
 } from "@pythnetwork/component-library/Card";
 import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
-import base58 from "bs58";
-import clsx from "clsx";
-import Generic from "cryptocurrency-icons/svg/color/generic.svg";
-import { Fragment, type ElementType } from "react";
+import { type ElementType } from "react";
 import { z } from "zod";
 
 import { AssetClassesDrawer } from "./asset-classes-drawer";
-import { YesterdaysPricesProvider, ChangePercent } from "./change-percent";
 import { ComingSoonList } from "./coming-soon-list";
 import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
-import { getIcon } from "../../icons";
 import { client } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
-import { CopyButton } from "../CopyButton";
-import { LivePrice } from "../LivePrices";
+import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
+import { FeedKey } from "../FeedKey";
+import { LivePrice, LiveConfidence, LiveValue } from "../LivePrices";
+import { PriceFeedTag } from "../PriceFeedTag";
 
 const PRICE_FEEDS_ANCHOR = "priceFeeds";
 
@@ -53,7 +49,7 @@ export const PriceFeeds = async () => {
     <div className={styles.priceFeeds}>
       <h1 className={styles.header}>Price Feeds</h1>
       <div className={styles.body}>
-        <div className={styles.stats}>
+        <section className={styles.stats}>
           <StatCard
             variant="primary"
             header="Active Feeds"
@@ -79,15 +75,8 @@ export const PriceFeeds = async () => {
               corner={<Info weight="fill" />}
             />
           </AssetClassesDrawer>
-        </div>
-        <YesterdaysPricesProvider
-          symbolsToFeedKeys={Object.fromEntries(
-            featuredRecentlyAdded.map(({ symbol, product }) => [
-              symbol,
-              product.price_account,
-            ]),
-          )}
-        >
+        </section>
+        <YesterdaysPricesProvider feeds={featuredRecentlyAdded}>
           <FeaturedFeedsCard
             title="Recently added"
             icon={<StackPlus />}
@@ -115,24 +104,18 @@ export const PriceFeeds = async () => {
                 }
               >
                 <ComingSoonList
-                  comingSoonFeeds={priceFeeds.comingSoon.map(
-                    ({ symbol, product }) => ({
-                      symbol,
-                      id: product.price_account,
-                      displaySymbol: product.display_symbol,
-                      assetClassAsString: product.asset_type,
-                      priceFeedName: (
-                        <PriceFeedNameAndIcon>
-                          {product.display_symbol}
-                        </PriceFeedNameAndIcon>
-                      ),
-                      assetClass: (
-                        <Badge variant="neutral" style="outline" size="xs">
-                          {product.asset_type.toUpperCase()}
-                        </Badge>
-                      ),
-                    }),
-                  )}
+                  comingSoonFeeds={priceFeeds.comingSoon.map((feed) => ({
+                    symbol: feed.symbol,
+                    id: feed.product.price_account,
+                    displaySymbol: feed.product.display_symbol,
+                    assetClassAsString: feed.product.asset_type,
+                    priceFeedName: <PriceFeedTag compact feed={feed} />,
+                    assetClass: (
+                      <Badge variant="neutral" style="outline" size="xs">
+                        {feed.product.asset_type.toUpperCase()}
+                      </Badge>
+                    ),
+                  }))}
                 />
               </Drawer>
             </DrawerTrigger>
@@ -140,46 +123,32 @@ export const PriceFeeds = async () => {
         />
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
-          nameLoadingSkeleton={
-            <div className={styles.priceFeedNameAndIcon}>
-              <Skeleton
-                className={clsx(styles.priceFeedIcon, styles.skeleton)}
-                fill
+          nameLoadingSkeleton={<PriceFeedTag compact />}
+          priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
+            symbol: feed.symbol,
+            id: feed.product.price_account,
+            displaySymbol: feed.product.display_symbol,
+            assetClassAsString: feed.product.asset_type,
+            exponent: <LiveValue field="exponent" feed={feed} />,
+            numPublishers: <LiveValue field="numQuoters" feed={feed} />,
+            price: <LivePrice feed={feed} />,
+            confidenceInterval: <LiveConfidence feed={feed} />,
+            weeklySchedule: feed.product.weekly_schedule,
+            priceFeedName: <PriceFeedTag compact feed={feed} />,
+            assetClass: (
+              <Badge variant="neutral" style="outline" size="xs">
+                {feed.product.asset_type.toUpperCase()}
+              </Badge>
+            ),
+            priceFeedId: (
+              <FeedKey
+                className={styles.feedKey ?? ""}
+                size="xs"
+                variant="ghost"
+                feed={feed}
               />
-              <div className={styles.priceFeedName}>
-                <Skeleton width={20} />
-              </div>
-            </div>
-          }
-          priceFeeds={priceFeeds.activeFeeds.map(
-            ({ symbol, product, price }) => ({
-              symbol,
-              id: product.price_account,
-              displaySymbol: product.display_symbol,
-              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
-                  className={styles.priceFeedId ?? ""}
-                  text={toHex(product.price_account)}
-                >
-                  {toTruncatedHex(product.price_account)}
-                </CopyButton>
-              ),
-            }),
-          )}
+            ),
+          }))}
         />
       </div>
     </div>
@@ -193,6 +162,7 @@ type FeaturedFeedsCardProps<T extends ElementType> = Omit<
   showPrices?: boolean | undefined;
   linkFeeds?: boolean | undefined;
   feeds: {
+    symbol: string;
     product: {
       display_symbol: string;
       price_account: string;
@@ -209,26 +179,20 @@ const FeaturedFeedsCard = <T extends ElementType>({
 }: FeaturedFeedsCardProps<T>) => (
   <Card {...props}>
     <div className={styles.featuredFeeds}>
-      {feeds.map(({ product }) => (
+      {feeds.map((feed) => (
         <Card
-          key={product.price_account}
+          key={feed.product.price_account}
           variant="tertiary"
-          {...(linkFeeds && { href: "#" })}
+          {...(linkFeeds && {
+            href: `/price-feeds/${encodeURIComponent(feed.symbol)}`,
+          })}
         >
           <div className={styles.feedCardContents}>
-            <div className={styles.priceFeedNameAndDescription}>
-              <PriceFeedIcon>{product.display_symbol}</PriceFeedIcon>
-              <div className={styles.nameAndDescription}>
-                <PriceFeedName>{product.display_symbol}</PriceFeedName>
-                <div className={styles.description}>
-                  {product.description.split("/")[0]}
-                </div>
-              </div>
-            </div>
+            <PriceFeedTag feed={feed} />
             {showPrices && (
               <div className={styles.prices}>
-                <LivePrice account={product.price_account} />
-                <ChangePercent feedKey={product.price_account} />
+                <LivePrice feed={feed} />
+                <ChangePercent className={styles.changePercent} feed={feed} />
               </div>
             )}
           </div>
@@ -238,53 +202,6 @@ const FeaturedFeedsCard = <T extends ElementType>({
   </Card>
 );
 
-const PriceFeedNameAndIcon = ({ children }: { children: string }) => (
-  <div className={styles.priceFeedNameAndIcon}>
-    <PriceFeedIcon>{children}</PriceFeedIcon>
-    <PriceFeedName>{children}</PriceFeedName>
-  </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 PriceFeedName = ({ children }: { children: string }) => {
-  const [firstPart, ...parts] = children.split("/");
-
-  return (
-    <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 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();
   const priceFeeds = priceFeedsSchema.parse(

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

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

+ 8 - 10
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -12,7 +12,7 @@ import { type ReactNode, Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import { serialize, useQueryParams } from "./query-params";
-import { SKELETON_WIDTH, LivePrice, LiveConfidence } from "../LivePrices";
+import { SKELETON_WIDTH } from "../LivePrices";
 
 type Props = {
   id: string;
@@ -25,8 +25,10 @@ type PriceFeed = {
   id: string;
   displaySymbol: string;
   assetClassAsString: string;
-  exponent: number;
-  numPublishers: number;
+  exponent: ReactNode;
+  numPublishers: ReactNode;
+  price: ReactNode;
+  confidenceInterval: ReactNode;
   priceFeedId: ReactNode;
   priceFeedName: ReactNode;
   assetClass: ReactNode;
@@ -85,14 +87,10 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
   );
   const rows = useMemo(
     () =>
-      paginatedFeeds.map(({ id, ...data }) => ({
+      paginatedFeeds.map(({ id, symbol, ...data }) => ({
         id,
-        href: "#",
-        data: {
-          ...data,
-          price: <LivePrice account={id} />,
-          confidenceInterval: <LiveConfidence account={id} />,
-        },
+        href: `/price-feeds/${encodeURIComponent(symbol)}`,
+        data,
       })),
     [paginatedFeeds],
   );

+ 9 - 7
apps/insights/src/components/Publishers/index.module.scss

@@ -149,21 +149,23 @@
     border-radius: theme.border-radius("full");
   }
 
+  .key {
+    margin: 0 -#{theme.button-padding("sm", true)};
+  }
+
   .nameAndKey {
     display: flex;
     flex-flow: column nowrap;
     gap: theme.spacing(1);
     align-items: flex-start;
 
-    .publisherKey {
-      color: theme.color("link", "normal");
-      font-weight: theme.font-weight("medium");
-      font-size: theme.font-size("xxs");
+    .name {
+      color: theme.color("heading");
     }
-  }
 
-  .name {
-    color: theme.color("heading");
+    .key {
+      margin: 0 -#{theme.button-padding("xs", true)};
+    }
   }
 
   &[data-is-undisclosed] {

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

@@ -36,7 +36,7 @@ export const Publishers = async () => {
     <div className={styles.publishers}>
       <h1 className={styles.header}>Publishers</h1>
       <div className={styles.body}>
-        <div className={styles.stats}>
+        <section className={styles.stats}>
           <StatCard
             variant="primary"
             header="Active Publishers"
@@ -115,15 +115,17 @@ export const Publishers = async () => {
               <div className={styles.legend}>
                 <Label className={styles.title}>PYTH Staking Pool</Label>
                 <p className={styles.poolUsed}>
-                  <FormattedTokens mode="wholePart">
-                    {oisStats.totalStaked}
-                  </FormattedTokens>
+                  <FormattedTokens
+                    mode="wholePart"
+                    tokens={oisStats.totalStaked}
+                  />
                 </p>
                 <p className={styles.poolTotal}>
                   /{" "}
-                  <FormattedTokens mode="wholePart">
-                    {BigInt(oisStats.maxPoolSize ?? 0)}
-                  </FormattedTokens>
+                  <FormattedTokens
+                    mode="wholePart"
+                    tokens={BigInt(oisStats.maxPoolSize ?? 0)}
+                  />
                 </p>
               </div>
             </SemicircleMeter>
@@ -134,7 +136,7 @@ export const Publishers = async () => {
                 stat={
                   <>
                     <TokenIcon />
-                    <FormattedTokens>{oisStats.totalStaked}</FormattedTokens>
+                    <FormattedTokens tokens={oisStats.totalStaked} />
                   </>
                 }
               />
@@ -144,15 +146,13 @@ export const Publishers = async () => {
                 stat={
                   <>
                     <TokenIcon />
-                    <FormattedTokens>
-                      {oisStats.rewardsDistributed}
-                    </FormattedTokens>
+                    <FormattedTokens tokens={oisStats.rewardsDistributed} />
                   </>
                 }
               />
             </div>
           </Card>
-        </div>
+        </section>
         <PublishersCard
           className={styles.publishersCard}
           rankingLoadingSkeleton={
@@ -178,7 +178,7 @@ export const Publishers = async () => {
             ({ key, rank, numSymbols, medianScore }) => ({
               id: key,
               nameAsString: lookupPublisher(key)?.name,
-              name: <PublisherName>{key}</PublisherName>,
+              name: <PublisherName publisherKey={key} />,
               ranking: <Ranking>{rank}</Ranking>,
               activeFeeds: numSymbols,
               inactiveFeeds: totalFeeds - numSymbols,
@@ -195,8 +195,8 @@ const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
   <span className={clsx(styles.ranking, className)} {...props} />
 );
 
-const PublisherName = ({ children }: { children: string }) => {
-  const knownPublisher = lookupPublisher(children);
+const PublisherName = ({ publisherKey }: { publisherKey: string }) => {
+  const knownPublisher = lookupPublisher(publisherKey);
   const Icon = knownPublisher?.icon.color ?? UndisclosedIcon;
   const name = knownPublisher?.name ?? "Undisclosed";
   return (
@@ -208,13 +208,23 @@ const PublisherName = ({ children }: { children: string }) => {
       {knownPublisher ? (
         <div className={styles.nameAndKey}>
           <div className={styles.name}>{name}</div>
-          <CopyButton className={styles.publisherKey ?? ""} text={children}>
-            {`${children.slice(0, 4)}...${children.slice(-4)}`}
+          <CopyButton
+            size="xs"
+            variant="ghost"
+            className={styles.key ?? ""}
+            text={publisherKey}
+          >
+            {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`}
           </CopyButton>
         </div>
       ) : (
-        <CopyButton className={styles.name ?? ""} text={children}>
-          {`${children.slice(0, 4)}...${children.slice(-4)}`}
+        <CopyButton
+          size="sm"
+          variant="ghost"
+          className={styles.key ?? ""}
+          text={publisherKey}
+        >
+          {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`}
         </CopyButton>
       )}
     </div>

+ 6 - 5
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -128,7 +128,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
         href: "#",
         data: {
           ...data,
-          medianScore: <PublisherScore>{medianScore}</PublisherScore>,
+          medianScore: <PublisherScore score={medianScore} />,
         },
       })),
     [paginatedPublishers],
@@ -283,14 +283,15 @@ const PublishersCardContents = ({
 );
 
 type PublisherScoreProps = {
-  children: number;
+  score: number;
 };
 
-const PublisherScore = ({ children }: PublisherScoreProps) => (
+const PublisherScore = ({ score }: PublisherScoreProps) => (
   <Meter
-    value={children}
+    value={score}
     maxValue={1}
     style={{ "--width": PUBLISHER_SCORE_WIDTH } as CSSProperties}
+    aria-label="Score"
   >
     {({ percentage }) => (
       <div
@@ -301,7 +302,7 @@ const PublisherScore = ({ children }: PublisherScoreProps) => (
           className={styles.fill}
           style={{ width: `${(50 + percentage / 2).toString()}%` }}
         >
-          {children.toFixed(2)}
+          {score.toFixed(2)}
         </div>
       </div>
     )}

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

@@ -1,5 +1,4 @@
 import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
-import { AppTabs } from "@pythnetwork/component-library/AppTabs";
 import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
 import { Link } from "@pythnetwork/component-library/Link";
 import clsx from "clsx";
@@ -8,6 +7,7 @@ import type { ComponentProps } from "react";
 import styles from "./header.module.scss";
 import Logo from "./logo.svg";
 import { SearchButton } from "./search-button";
+import { MainNavTabs } from "./tabs";
 import { ThemeSwitch } from "./theme-switch";
 
 export const Header = ({ className, ...props }: ComponentProps<"header">) => (
@@ -21,17 +21,7 @@ export const Header = ({ className, ...props }: ComponentProps<"header">) => (
           <div className={styles.logoLabel}>Pyth Homepage</div>
         </Link>
         <div className={styles.appName}>Insights</div>
-        <AppTabs
-          tabs={[
-            { href: "/", id: "/", children: "Overview" },
-            { href: "/publishers", id: "/publishers", children: "Publishers" },
-            {
-              href: "/price-feeds",
-              id: "/price-feeds",
-              children: "Price Feeds",
-            },
-          ]}
-        />
+        <MainNavTabs />
       </div>
       <div className={styles.rightMenu}>
         <Button beforeIcon={Lifebuoy} variant="ghost" size="sm" rounded>

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

@@ -4,6 +4,7 @@ $header-height: theme.spacing(20);
 
 .root {
   scroll-padding-top: $header-height;
+  overflow-x: hidden;
 
   .tabRoot {
     display: grid;

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

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

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

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

+ 72 - 8
apps/insights/src/components/Root/tabs.tsx

@@ -1,24 +1,88 @@
 "use client";
 
+import { MainNavTabs as MainNavTabsComponent } from "@pythnetwork/component-library/MainNavTabs";
 import {
   UnstyledTabPanel,
   UnstyledTabs,
 } from "@pythnetwork/component-library/UnstyledTabs";
-import { useSelectedLayoutSegment } from "next/navigation";
-import type { ComponentProps } from "react";
+import { useSelectedLayoutSegment, usePathname } from "next/navigation";
+import { type ComponentProps } from "react";
+
+import { type VariantArg, LayoutTransition } from "../LayoutTransition";
 
 export const TabRoot = (
   props: Omit<ComponentProps<typeof UnstyledTabs>, "selectedKey">,
 ) => {
-  const layoutSegment = useSelectedLayoutSegment();
+  const tabId = useSelectedLayoutSegment() ?? "";
 
-  return <UnstyledTabs selectedKey={`/${layoutSegment ?? ""}`} {...props} />;
+  return <UnstyledTabs selectedKey={tabId} {...props} />;
 };
 
-export const TabPanel = (
-  props: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">,
+export const MainNavTabs = (
+  props: Omit<
+    ComponentProps<typeof MainNavTabsComponent>,
+    "pathname" | "items"
+  >,
 ) => {
-  const layoutSegment = useSelectedLayoutSegment();
+  const pathname = usePathname();
 
-  return <UnstyledTabPanel id={`/${layoutSegment ?? ""}`} {...props} />;
+  return (
+    <MainNavTabsComponent
+      pathname={pathname}
+      items={[
+        { href: "/", id: "", children: "Overview" },
+        { href: "/publishers", id: "publishers", children: "Publishers" },
+        {
+          href: "/price-feeds",
+          id: "price-feeds",
+          children: "Price Feeds",
+        },
+      ]}
+      {...props}
+    />
+  );
 };
+
+export const TabPanel = ({
+  children,
+  ...props
+}: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">) => {
+  const tabId = useSelectedLayoutSegment() ?? "";
+
+  return (
+    <UnstyledTabPanel key="tabpanel" id={tabId} {...props}>
+      {(args) => (
+        <LayoutTransition
+          variants={{
+            initial: (custom) => ({
+              opacity: 0,
+              x: isMovingLeft(custom) ? "-2%" : "2%",
+            }),
+            exit: (custom) => ({
+              opacity: 0,
+              x: isMovingLeft(custom) ? "2%" : "-2%",
+              transition: {
+                x: { type: "spring", bounce: 0 },
+              },
+            }),
+          }}
+          initial="initial"
+          animate={{
+            opacity: 1,
+            x: 0,
+            transition: {
+              x: { type: "spring", bounce: 0 },
+            },
+          }}
+          exit="exit"
+        >
+          {typeof children === "function" ? children(args) : children}
+        </LayoutTransition>
+      )}
+    </UnstyledTabPanel>
+  );
+};
+
+const isMovingLeft = ({ segment, prevSegment }: VariantArg): boolean =>
+  segment === null ||
+  (segment === "publishers" && prevSegment === "price-feeds");

+ 15 - 17
apps/insights/src/components/Root/theme-switch.tsx

@@ -6,7 +6,7 @@ import { Moon } from "@phosphor-icons/react/dist/ssr/Moon";
 import { Sun } from "@phosphor-icons/react/dist/ssr/Sun";
 import { Button } from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
-import { m, LazyMotion, domMax } from "framer-motion";
+import { motion } from "motion/react";
 import { useTheme } from "next-themes";
 import {
   type ComponentProps,
@@ -33,20 +33,18 @@ export const ThemeSwitch = ({ className, ...props }: Props) => {
   }, [theme, setTheme]);
 
   return (
-    <LazyMotion features={domMax} strict>
-      <Button
-        variant="ghost"
-        size="sm"
-        hideText
-        onPress={toggleTheme}
-        beforeIcon={IconPath}
-        className={clsx(styles.themeSwitch, className)}
-        rounded
-        {...props}
-      >
-        Dark mode
-      </Button>
-    </LazyMotion>
+    <Button
+      variant="ghost"
+      size="sm"
+      hideText
+      onPress={toggleTheme}
+      beforeIcon={IconPath}
+      className={clsx(styles.themeSwitch, className)}
+      rounded
+      {...props}
+    >
+      Dark mode
+    </Button>
   );
 };
 
@@ -71,7 +69,7 @@ type IconMovementProps = Omit<IconProps, "offset"> & {
 };
 
 const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => (
-  <m.div
+  <motion.div
     // @ts-expect-error Looks like framer-motion has a bug in it's typings...
     className={styles.iconMovement}
     animate={{ offsetDistance: offset }}
@@ -79,7 +77,7 @@ const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => (
     initial={false}
   >
     <Icon className={styles.icon} {...props} />
-  </m.div>
+  </motion.div>
 );
 
 const useOffsets = () => {

+ 1 - 7
apps/insights/src/static-data/price-feeds.tsx

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

+ 1 - 1
apps/staking/next-env.d.ts

@@ -2,4 +2,4 @@
 /// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 2 - 1
governance/xc_admin/packages/xc_admin_frontend/components/InstructionViews/WormholeInstructionView.tsx

@@ -16,6 +16,7 @@ import {
   getProgramName,
 } from '@pythnetwork/xc-admin-common'
 import { AccountMeta, PublicKey } from '@solana/web3.js'
+import type { ReactNode } from 'react'
 import CopyText from '../common/CopyText'
 import { ParsedAccountPubkeyRow, SignerTag, WritableTag } from './AccountUtils'
 import { usePythContext } from '../../contexts/PythContext'
@@ -31,7 +32,7 @@ const GovernanceInstructionView = ({
 }: {
   instruction: PythGovernanceAction
   actionName: string
-  content: JSX.Element
+  content: ReactNode
 }) => {
   return (
     <div className="space-y-4">

+ 8 - 2
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposal.tsx

@@ -9,7 +9,13 @@ import {
 } from '@solana/web3.js'
 import SquadsMesh from '@sqds/mesh'
 import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
-import { Fragment, useContext, useEffect, useState } from 'react'
+import {
+  type ReactNode,
+  Fragment,
+  useContext,
+  useEffect,
+  useState,
+} from 'react'
 import toast from 'react-hot-toast'
 import {
   AnchorMultisigInstruction,
@@ -53,7 +59,7 @@ const IconWithTooltip = ({
   icon,
   tooltipText,
 }: {
-  icon: JSX.Element
+  icon: ReactNode
   tooltipText: string
 }) => {
   return (

+ 1 - 1
governance/xc_admin/packages/xc_admin_frontend/next-env.d.ts

@@ -2,4 +2,4 @@
 /// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

+ 40 - 0
packages/component-library/src/Breadcrumbs/index.module.scss

@@ -0,0 +1,40 @@
+@use "../theme";
+
+.breadcrumbs {
+  display: flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  gap: theme.spacing(4);
+  list-style: none;
+  margin: 0;
+  padding: 0;
+
+  .breadcrumb {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    gap: theme.spacing(4);
+
+    .separator {
+      color: theme.color("muted");
+      width: theme.spacing(4);
+      height: theme.spacing(4);
+    }
+
+    .crumb,
+    .current {
+      font-size: theme.font-size();
+      font-style: normal;
+      font-weight: theme.font-weight("medium");
+      line-height: 1;
+    }
+
+    .crumb {
+      color: theme.color("link", "primary");
+    }
+
+    .current {
+      color: theme.color("heading");
+    }
+  }
+}

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

@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Breadcrumbs as BreadcrumbsComponent } from "./index.js";
+
+const meta = {
+  component: BreadcrumbsComponent,
+  argTypes: {
+    label: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    items: {
+      table: {
+        disable: true,
+      },
+    },
+  },
+} satisfies Meta<typeof BreadcrumbsComponent>;
+export default meta;
+
+export const Breadcrumbs = {
+  args: {
+    label: "Breadcrumbs",
+    items: [
+      { href: "/", label: "Home" },
+      { href: "/foo", label: "Foo" },
+      { label: "Bar" },
+    ],
+  },
+} satisfies StoryObj<typeof BreadcrumbsComponent>;

+ 72 - 0
packages/component-library/src/Breadcrumbs/index.tsx

@@ -0,0 +1,72 @@
+"use client";
+
+import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
+import { House } from "@phosphor-icons/react/dist/ssr/House";
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+import { ButtonLink } from "../Button/index.js";
+import { Link } from "../Link/index.js";
+import {
+  UnstyledBreadcrumbs,
+  UnstyledBreadcrumb,
+} from "../UnstyledBreadcrumbs/index.js";
+
+type OwnProps = {
+  label: string;
+  items: [
+    ...{
+      href: string;
+      label: string;
+    }[],
+    { label: string },
+  ];
+};
+type Props = Omit<ComponentProps<typeof UnstyledBreadcrumbs>, keyof OwnProps> &
+  OwnProps;
+
+export const Breadcrumbs = ({ label, className, items, ...props }: Props) => (
+  <nav aria-label={label}>
+    <UnstyledBreadcrumbs
+      className={clsx(styles.breadcrumbs, className)}
+      items={items.map((item) => ({
+        id: "href" in item ? item.href : item.label,
+        ...item,
+      }))}
+      {...props}
+    >
+      {(item) => (
+        <UnstyledBreadcrumb className={styles.breadcrumb ?? ""}>
+          {"href" in item ? (
+            <>
+              {item.href === "/" ? (
+                <ButtonLink
+                  size="xs"
+                  variant="outline"
+                  // I'm not quite sure why this is triggering, I'll need to
+                  // figure this out later.  Something in Phosphor's types is
+                  // incorrect and is making eslint think this icon is an error
+                  // object somehow...
+                  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+                  beforeIcon={House}
+                  hideText
+                  href="/"
+                >
+                  {item.label}
+                </ButtonLink>
+              ) : (
+                <Link href={item.href} className={styles.crumb ?? ""} invert>
+                  {item.label}
+                </Link>
+              )}
+              <CaretRight className={styles.separator} />
+            </>
+          ) : (
+            <div className={styles.current}>{item.label}</div>
+          )}
+        </UnstyledBreadcrumb>
+      )}
+    </UnstyledBreadcrumbs>
+  </nav>
+);

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

@@ -19,11 +19,12 @@
       background: theme.color("background", "primary");
       border: 1px solid theme.color("border");
       border-radius: theme.border-radius("3xl");
-      overflow: hidden;
       outline: none;
       display: flex;
       flex-flow: column nowrap;
       height: 100%;
+      overflow-y: hidden;
+      padding-bottom: theme.border-radius("3xl");
 
       .heading {
         padding: theme.spacing(4);
@@ -33,6 +34,7 @@
         justify-content: space-between;
         align-items: center;
         color: theme.color("heading");
+        flex: none;
 
         .title {
           @include theme.h4;
@@ -42,6 +44,11 @@
           gap: theme.spacing(3);
         }
       }
+
+      .body {
+        flex: 1;
+        overflow-y: auto;
+      }
     }
   }
 }

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

@@ -58,7 +58,9 @@ export const Drawer = ({ title, children, className, ...props }: Props) => (
             Close
           </Button>
         </div>
-        {typeof children === "function" ? children(state) : children}
+        <div className={styles.body}>
+          {typeof children === "function" ? children(state) : children}
+        </div>
       </Dialog>
     )}
   </Modal>

+ 8 - 0
packages/component-library/src/Link/index.module.scss

@@ -27,4 +27,12 @@
     cursor: not-allowed;
     color: theme.color("button", "disabled", "foreground");
   }
+
+  &[data-invert] {
+    text-decoration: none;
+
+    &[data-hovered] {
+      text-decoration: underline;
+    }
+  }
 }

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

@@ -29,6 +29,12 @@ const meta = {
         category: "State",
       },
     },
+    invert: {
+      control: "boolean",
+      table: {
+        category: "Link",
+      },
+    },
   },
 } satisfies Meta<typeof LinkComponent>;
 export default meta;
@@ -39,5 +45,6 @@ export const Link = {
     href: "https://www.pyth.network",
     target: "_blank",
     isDisabled: false,
+    invert: false,
   },
 } satisfies StoryObj<typeof LinkComponent>;

+ 13 - 3
packages/component-library/src/Link/index.tsx

@@ -1,9 +1,19 @@
 import clsx from "clsx";
-import type { LinkProps } from "react-aria-components";
+import type { ComponentProps } from "react";
 
 import styles from "./index.module.scss";
 import { UnstyledLink } from "../UnstyledLink/index.js";
 
-export const Link = ({ className, ...props }: LinkProps) => (
-  <UnstyledLink className={clsx(styles.link, className)} {...props} />
+type OwnProps = {
+  invert?: boolean | undefined;
+};
+type Props = Omit<ComponentProps<typeof UnstyledLink>, keyof OwnProps> &
+  OwnProps;
+
+export const Link = ({ className, invert, ...props }: Props) => (
+  <UnstyledLink
+    className={clsx(styles.link, className)}
+    data-invert={invert ? "" : undefined}
+    {...props}
+  />
 );

+ 27 - 3
packages/component-library/src/AppTabs/index.module.scss → packages/component-library/src/MainNavTabs/index.module.scss

@@ -1,6 +1,6 @@
 @use "../theme";
 
-.appTabs {
+.mainNavTabs {
   gap: theme.spacing(2);
 
   @include theme.row;
@@ -17,7 +17,9 @@
       outline: 4px solid transparent;
       outline-offset: 0;
       z-index: -1;
-      transition: outline-color 200ms linear;
+      transition-property: background-color, outline-color;
+      transition-duration: 100ms;
+      transition-timing-function: linear;
     }
 
     &[data-focus-visible] {
@@ -31,7 +33,29 @@
 
     &[data-selected] {
       color: theme.color("button", "solid", "foreground");
-      cursor: default;
+      pointer-events: none;
+
+      &[data-selectable] {
+        pointer-events: auto;
+
+        &[data-hovered] .bubble {
+          background-color: theme.color(
+            "button",
+            "solid",
+            "background",
+            "hover"
+          );
+        }
+
+        &[data-pressed] .bubble {
+          background-color: theme.color(
+            "button",
+            "solid",
+            "background",
+            "active"
+          );
+        }
+      }
     }
   }
 }

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

@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { MainNavTabs as MainNavTabsComponent } from "./index.js";
+import { UnstyledTabs } from "../UnstyledTabs/index.js";
+
+const meta = {
+  component: MainNavTabsComponent,
+  argTypes: {
+    items: {
+      table: {
+        disable: true,
+      },
+    },
+  },
+} satisfies Meta<typeof MainNavTabsComponent>;
+export default meta;
+
+export const MainNavTabs = {
+  decorators: [
+    (Story) => (
+      <UnstyledTabs>
+        <Story />
+      </UnstyledTabs>
+    ),
+  ],
+  args: {
+    items: [
+      { id: "foo", children: "Foo" },
+      { id: "bar", children: "Bar" },
+    ],
+  },
+} satisfies StoryObj<typeof MainNavTabsComponent>;

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

@@ -0,0 +1,53 @@
+"use client";
+
+import clsx from "clsx";
+import { motion } from "motion/react";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+import buttonStyles from "../Button/index.module.scss";
+import { UnstyledTab, UnstyledTabList } from "../UnstyledTabs/index.js";
+
+type OwnProps = {
+  pathname?: string | undefined;
+  items: ComponentProps<typeof UnstyledTab>[];
+};
+type Props = Omit<ComponentProps<typeof UnstyledTabList>, keyof OwnProps> &
+  OwnProps;
+
+export const MainNavTabs = ({ className, pathname, ...props }: Props) => (
+  <UnstyledTabList
+    aria-label="Main Navigation"
+    className={clsx(styles.mainNavTabs, className)}
+    dependencies={[pathname]}
+    {...props}
+  >
+    {({ className: tabClassName, children, ...tab }) => (
+      <UnstyledTab
+        className={clsx(styles.tab, buttonStyles.button, tabClassName)}
+        data-size="sm"
+        data-variant="ghost"
+        data-rounded
+        data-selectable={pathname === tab.href ? undefined : ""}
+        {...tab}
+      >
+        {(args) => (
+          <>
+            {args.isSelected && (
+              <motion.span
+                layoutId="bubble"
+                // @ts-expect-error Looks like framer-motion has a bug in it's typings...
+                className={styles.bubble}
+                transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+                style={{ originY: "top" }}
+              />
+            )}
+            <span className={buttonStyles.text}>
+              {typeof children === "function" ? children(args) : children}
+            </span>
+          </>
+        )}
+      </UnstyledTab>
+    )}
+  </UnstyledTabList>
+);

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

@@ -36,15 +36,18 @@
   }
 
   .table {
-    border-collapse: collapse;
+    border-spacing: 0;
 
     .cell {
       padding-left: theme.spacing(3);
       padding-right: theme.spacing(3);
       white-space: nowrap;
       border: 0;
-      outline: none;
+      outline: theme.spacing(0.5) solid transparent;
       width: calc(theme.spacing(1) * var(--width));
+      outline-offset: -#{theme.spacing(0.5)};
+      background-color: theme.color("background", "primary");
+      transition: outline-color 100ms linear;
 
       &:first-child {
         padding-left: theme.spacing(4);
@@ -70,21 +73,35 @@
         width: 100%;
       }
 
+      &[data-sticky] {
+        position: sticky;
+        left: 0;
+        z-index: 1;
+        border-right: 1px solid theme.color("border");
+      }
+
       &[data-focus-visible] {
-        outline: theme.spacing(0.5) solid theme.color("focus");
+        outline-color: theme.color("focus");
       }
     }
 
     .tableHeader {
-      border-bottom: 1px solid theme.color("background", "secondary");
       font-size: theme.font-size("xs");
       line-height: theme.spacing(4);
       color: theme.color("muted");
 
       .cell {
+        border-bottom: 1px solid theme.color("background", "secondary");
         font-weight: theme.font-weight("medium");
         padding-top: theme.spacing(3);
         padding-bottom: theme.spacing(3);
+        position: sticky;
+        top: 0;
+        z-index: 1;
+
+        &[data-sticky] {
+          z-index: 2;
+        }
       }
     }
 
@@ -95,13 +112,25 @@
       font-weight: theme.font-weight("medium");
 
       .row {
-        background-color: transparent;
-        transition-property: background-color;
-        transition-duration: 100ms;
-        transition-timing-function: linear;
-        outline: none;
+        outline: theme.spacing(0.5) solid transparent;
+        outline-offset: -#{theme.spacing(0.5)};
+        transition: outline-color 100ms linear;
+
+        &[data-focus-visible] {
+          outline: theme.spacing(0.5) solid theme.color("focus");
+        }
+
+        &[data-href] {
+          cursor: pointer;
+        }
 
-        &[data-hovered] {
+        .cell {
+          padding-top: theme.spacing(4);
+          padding-bottom: theme.spacing(4);
+          transition: background-color 100ms linear;
+        }
+
+        &[data-hovered] .cell {
           background-color: theme.color(
             "button",
             "outline",
@@ -110,7 +139,7 @@
           );
         }
 
-        &[data-pressed] {
+        &[data-pressed] .cell {
           background-color: theme.color(
             "button",
             "outline",
@@ -118,19 +147,6 @@
             "active"
           );
         }
-
-        &[data-focus-visible] {
-          outline: theme.spacing(0.5) solid theme.color("focus");
-        }
-
-        &[data-href] {
-          cursor: pointer;
-        }
-
-        .cell {
-          padding-top: theme.spacing(4);
-          padding-bottom: theme.spacing(4);
-        }
       }
     }
   }
@@ -140,25 +156,49 @@
   }
 
   &[data-divide] {
-    .tableHeader {
-      border-color: theme.color("border");
-    }
+    .table {
+      // This rule has lower specificity than a rule above which applies the
+      // background color to hovered / pressed body cells, but csslint has no
+      // way to understand that .tableHeader and .tableBody are mutually
+      // exclusive and so these rules will never override other other.
+      // stylelint-disable-next-line no-descending-specificity
+      .tableHeader .cell {
+        border-color: theme.color("border");
+      }
 
-    .tableBody .row .cell {
-      border-bottom: 1px solid theme.color("background", "secondary");
+      .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");
+    .table {
+      .tableHeader .cell {
+        &:first-child {
+          border-top-left-radius: theme.border-radius("xl");
+        }
+
+        &:last-child {
+          border-top-right-radius: theme.border-radius("xl");
+        }
       }
 
-      &:last-child {
+      .tableBody .row:last-child {
+        border-bottom-left-radius: theme.border-radius("xl");
         border-bottom-right-radius: theme.border-radius("xl");
+
+        .cell {
+          &:first-child {
+            border-bottom-left-radius: theme.border-radius("xl");
+          }
+
+          &:last-child {
+            border-bottom-right-radius: theme.border-radius("xl");
+          }
+        }
       }
     }
   }

+ 17 - 14
packages/component-library/src/Table/index.tsx

@@ -39,6 +39,7 @@ export type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
   name: ReactNode;
   id: T;
   fill?: boolean | undefined;
+  sticky?: boolean | undefined;
   alignment?: Alignment | undefined;
   width?: number | undefined;
 } & (
@@ -85,8 +86,8 @@ export const Table = <T extends string>({
         columns={columns}
         className={styles.tableHeader ?? ""}
       >
-        {({ fill, width, alignment, ...column }: ColumnConfig<T>) => (
-          <UnstyledColumn {...cellProps(alignment, width, fill)} {...column}>
+        {(column: ColumnConfig<T>) => (
+          <UnstyledColumn {...cellProps(column)} {...column}>
             {column.name}
           </UnstyledColumn>
         )}
@@ -104,8 +105,8 @@ export const Table = <T extends string>({
             className={styles.row ?? ""}
             columns={columns}
           >
-            {({ alignment, fill, width, ...column }: ColumnConfig<T>) => (
-              <UnstyledCell {...cellProps(alignment, width, fill)}>
+            {(column: ColumnConfig<T>) => (
+              <UnstyledCell {...cellProps(column)}>
                 {"loadingSkeleton" in column ? (
                   column.loadingSkeleton
                 ) : (
@@ -113,7 +114,7 @@ export const Table = <T extends string>({
                     width={
                       "loadingSkeletonWidth" in column
                         ? column.loadingSkeletonWidth
-                        : width
+                        : column.width
                     }
                   />
                 )}
@@ -127,9 +128,9 @@ export const Table = <T extends string>({
               columns={columns}
               {...row}
             >
-              {({ alignment, width, fill, id }: ColumnConfig<T>) => (
-                <UnstyledCell {...cellProps(alignment, width, fill)}>
-                  {data[id]}
+              {(column: ColumnConfig<T>) => (
+                <UnstyledCell {...cellProps(column)}>
+                  {data[column.id]}
                 </UnstyledCell>
               )}
             </UnstyledRow>
@@ -140,13 +141,15 @@ export const Table = <T extends string>({
   </div>
 );
 
-const cellProps = (
-  alignment: Alignment | undefined,
-  width: number | undefined,
-  fill: boolean | undefined,
-) => ({
+const cellProps = <T extends string>({
+  alignment,
+  width,
+  fill,
+  sticky,
+}: Pick<ColumnConfig<T>, "alignment" | "width" | "fill" | "sticky">) => ({
   className: styles.cell ?? "",
   "data-alignment": alignment ?? "left",
+  "data-fill": fill ? "" : undefined,
+  "data-sticky": sticky ? "" : undefined,
   ...(width && { style: { "--width": width } as CSSProperties }),
-  ...(fill && { "data-fill": "" }),
 });

+ 35 - 0
packages/component-library/src/Tabs/index.module.scss

@@ -0,0 +1,35 @@
+@use "../theme";
+
+.tabs {
+  border-bottom: 1px solid theme.color("border");
+
+  .tabList {
+    @include theme.max-width;
+
+    display: flex;
+    flex-flow: row nowrap;
+    gap: theme.spacing(2);
+    padding-bottom: theme.spacing(1);
+
+    .tab {
+      position: relative;
+
+      .underline {
+        position: absolute;
+        bottom: -#{theme.spacing(1.5)};
+        left: 0;
+        width: 100%;
+        height: theme.spacing(0.5);
+        background: theme.color("foreground");
+      }
+
+      &[data-selected] {
+        pointer-events: none;
+
+        &[data-selectable] {
+          pointer-events: auto;
+        }
+      }
+    }
+  }
+}

+ 7 - 7
packages/component-library/src/AppTabs/index.stories.tsx → packages/component-library/src/Tabs/index.stories.tsx

@@ -1,21 +1,21 @@
 import type { Meta, StoryObj } from "@storybook/react";
 
-import { AppTabs as AppTabsComponent } from "./index.js";
+import { Tabs as TabsComponent } from "./index.js";
 import { UnstyledTabs } from "../UnstyledTabs/index.js";
 
 const meta = {
-  component: AppTabsComponent,
+  component: TabsComponent,
   argTypes: {
-    tabs: {
+    items: {
       table: {
         disable: true,
       },
     },
   },
-} satisfies Meta<typeof AppTabsComponent>;
+} satisfies Meta<typeof TabsComponent>;
 export default meta;
 
-export const AppTabs = {
+export const Tabs = {
   decorators: [
     (Story) => (
       <UnstyledTabs>
@@ -24,9 +24,9 @@ export const AppTabs = {
     ),
   ],
   args: {
-    tabs: [
+    items: [
       { id: "foo", children: "Foo" },
       { id: "bar", children: "Bar" },
     ],
   },
-} satisfies StoryObj<typeof AppTabsComponent>;
+} satisfies StoryObj<typeof TabsComponent>;

+ 24 - 19
packages/component-library/src/AppTabs/index.tsx → packages/component-library/src/Tabs/index.tsx

@@ -1,50 +1,55 @@
 "use client";
 
 import clsx from "clsx";
-import { m, LazyMotion, domMax } from "motion/react";
+import { motion } from "motion/react";
 import type { ComponentProps } from "react";
 
 import styles from "./index.module.scss";
 import buttonStyles from "../Button/index.module.scss";
 import { UnstyledTab, UnstyledTabList } from "../UnstyledTabs/index.js";
 
-type TabListProps = {
-  tabs: ComponentProps<typeof UnstyledTab>[];
+type OwnProps = {
+  label: string;
+  pathname?: string | undefined;
+  items: ComponentProps<typeof UnstyledTab>[];
 };
+type Props = Omit<ComponentProps<typeof UnstyledTabList>, keyof OwnProps> &
+  OwnProps;
 
-export const AppTabs = ({ tabs }: TabListProps) => (
-  <LazyMotion features={domMax} strict>
+export const Tabs = ({ label, className, pathname, ...props }: Props) => (
+  <div className={clsx(styles.tabs, className)}>
     <UnstyledTabList
-      aria-label="Main Navigation"
-      className={styles.appTabs ?? ""}
-      items={tabs}
+      aria-label={label}
+      dependencies={[pathname]}
+      className={styles.tabList ?? ""}
+      {...props}
     >
-      {({ className, children, ...tab }) => (
+      {({ className: tabClassName, children, ...tab }) => (
         <UnstyledTab
-          className={clsx(styles.tab, buttonStyles.button, className)}
+          className={clsx(styles.tab, buttonStyles.button, tabClassName)}
           data-size="sm"
           data-variant="ghost"
-          data-rounded
+          data-selectable={pathname === tab.href ? undefined : ""}
           {...tab}
         >
           {(args) => (
             <>
+              <span className={buttonStyles.text}>
+                {typeof children === "function" ? children(args) : children}
+              </span>
               {args.isSelected && (
-                <m.span
-                  layoutId="bubble"
+                <motion.span
+                  layoutId="underline"
                   // @ts-expect-error Looks like framer-motion has a bug in it's typings...
-                  className={styles.bubble}
-                  transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+                  className={styles.underline}
+                  transition={{ type: "spring", bounce: 0.6, duration: 0.6 }}
                   style={{ originY: "top" }}
                 />
               )}
-              <span className={buttonStyles.text}>
-                {typeof children === "function" ? children(args) : children}
-              </span>
             </>
           )}
         </UnstyledTab>
       )}
     </UnstyledTabList>
-  </LazyMotion>
+  </div>
 );

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

@@ -0,0 +1,12 @@
+/**
+ * The react-aria components aren't marked as "use client" so it's a bit
+ * obnoxious to use them; this file just adds a client boundary and re-exports
+ * the react-aria components to avoid that problem.
+ */
+
+"use client";
+
+export {
+  Breadcrumbs as UnstyledBreadcrumbs,
+  Breadcrumb as UnstyledBreadcrumb,
+} from "react-aria-components";

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

@@ -425,6 +425,8 @@ $color: (
   "paragraph":
     light-dark(pallette-color("steel", 700), pallette-color("steel", 300)),
   "link": (
+    "primary":
+      light-dark(pallette-color("violet", 700), pallette-color("purple", 300)),
     "normal":
       light-dark(pallette-color("steel", 800), pallette-color("steel", 50)),
   ),

Разлика између датотеке није приказан због своје велике величине
+ 158 - 196
pnpm-lock.yaml


+ 5 - 5
pnpm-workspace.yaml

@@ -56,8 +56,8 @@ catalog:
   "@tailwindcss/forms": 0.5.9
   "@types/jest": 29.5.14
   "@types/node": 22.8.2
-  "@types/react": npm:types-react@19.0.0-rc.1
-  "@types/react-dom": npm:types-react-dom@19.0.0-rc.1
+  "@types/react": 19.0.1
+  "@types/react-dom": 19.0.2
   autoprefixer: 10.4.20
   bcp-47: 2.1.0
   bs58: 6.0.0
@@ -71,7 +71,7 @@ catalog:
   modern-normalize: 3.0.1
   motion: 11.11.17
   next-themes: 0.3.0
-  next: 15.0.3
+  next: 15.1.0
   nuqs: 2.1.2
   pino: 9.5.0
   postcss-loader: 8.1.1
@@ -79,8 +79,8 @@ catalog:
   prettier: 3.3.3
   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
+  react-dom: 19.0.0
+  react: 19.0.0
   recharts: 2.14.1
   sass-loader: 16.0.3
   sass: 1.80.7

Неке датотеке нису приказане због велике количине промена