Browse Source

feat(insights): make app responsive

Connor Prussin 9 months ago
parent
commit
2f1d51c7d3
86 changed files with 3111 additions and 1362 deletions
  1. 2 0
      .github/workflows/ci-turbo-build.yml
  2. 2 0
      .github/workflows/ci-turbo-test.yml
  3. 2 0
      .github/workflows/publish-js.yml
  4. 40 0
      apps/insights/src/components/Cards/index.module.scss
  5. 8 0
      apps/insights/src/components/Cards/index.tsx
  6. 84 0
      apps/insights/src/components/EntityList/index.module.scss
  7. 97 0
      apps/insights/src/components/EntityList/index.tsx
  8. 21 5
      apps/insights/src/components/Error/index.module.scss
  9. 12 4
      apps/insights/src/components/Explain/index.module.scss
  10. 21 19
      apps/insights/src/components/Explain/index.tsx
  11. 1 1
      apps/insights/src/components/NoResults/index.tsx
  12. 21 5
      apps/insights/src/components/NotFound/index.module.scss
  13. 70 44
      apps/insights/src/components/Overview/index.module.scss
  14. 31 28
      apps/insights/src/components/Overview/index.tsx
  15. 96 3
      apps/insights/src/components/PriceComponentDrawer/index.module.scss
  16. 99 4
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  17. 89 0
      apps/insights/src/components/PriceComponentsCard/index.module.scss
  18. 150 102
      apps/insights/src/components/PriceComponentsCard/index.tsx
  19. 39 20
      apps/insights/src/components/PriceFeed/layout.module.scss
  20. 18 30
      apps/insights/src/components/PriceFeed/layout.tsx
  21. 2 0
      apps/insights/src/components/PriceFeed/price-feed-select.module.scss
  22. 4 2
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  23. 9 0
      apps/insights/src/components/PriceFeed/reference-data.module.scss
  24. 3 3
      apps/insights/src/components/PriceFeed/reference-data.tsx
  25. 65 43
      apps/insights/src/components/PriceFeeds/index.module.scss
  26. 133 80
      apps/insights/src/components/PriceFeeds/index.tsx
  27. 27 0
      apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss
  28. 85 51
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  29. 31 21
      apps/insights/src/components/Publisher/layout.module.scss
  30. 14 2
      apps/insights/src/components/Publisher/layout.tsx
  31. 27 3
      apps/insights/src/components/Publisher/performance.module.scss
  32. 139 92
      apps/insights/src/components/Publisher/performance.tsx
  33. 15 0
      apps/insights/src/components/Publisher/top-feeds-table.module.scss
  34. 47 35
      apps/insights/src/components/Publisher/top-feeds-table.tsx
  35. 1 1
      apps/insights/src/components/PublisherTag/index.module.scss
  36. 77 52
      apps/insights/src/components/Publishers/index.module.scss
  37. 74 77
      apps/insights/src/components/Publishers/index.tsx
  38. 41 0
      apps/insights/src/components/Publishers/publishers-card.module.scss
  39. 45 14
      apps/insights/src/components/Publishers/publishers-card.tsx
  40. 36 34
      apps/insights/src/components/Root/footer.module.scss
  41. 6 4
      apps/insights/src/components/Root/footer.tsx
  42. 57 7
      apps/insights/src/components/Root/header.module.scss
  43. 34 7
      apps/insights/src/components/Root/header.tsx
  44. 21 3
      apps/insights/src/components/Root/index.module.scss
  45. 13 3
      apps/insights/src/components/Root/index.tsx
  46. 30 0
      apps/insights/src/components/Root/mobile-menu.module.scss
  47. 73 41
      apps/insights/src/components/Root/mobile-menu.tsx
  48. 52 0
      apps/insights/src/components/Root/mobile-nav-tabs.module.scss
  49. 62 0
      apps/insights/src/components/Root/mobile-nav-tabs.tsx
  50. 0 30
      apps/insights/src/components/Root/nav-link.tsx
  51. 8 7
      apps/insights/src/components/Root/search-button.tsx
  52. 159 54
      apps/insights/src/components/Root/search-dialog.module.scss
  53. 232 138
      apps/insights/src/components/Root/search-dialog.tsx
  54. 2 0
      apps/insights/src/components/Root/support-drawer.module.scss
  55. 71 76
      apps/insights/src/components/Root/support-drawer.tsx
  56. 2 19
      apps/insights/src/components/Root/tabs.tsx
  57. 5 3
      apps/insights/src/components/Score/index.tsx
  58. 1 0
      apps/insights/src/services/clickhouse.ts
  59. 1 0
      apps/insights/src/static-data/price-feeds.tsx
  60. 10 6
      packages/component-library/.storybook/preview.tsx
  61. 10 2
      packages/component-library/src/Breadcrumbs/index.module.scss
  62. 2 0
      packages/component-library/src/Button/index.module.scss
  63. 26 6
      packages/component-library/src/Card/index.module.scss
  64. 12 1
      packages/component-library/src/Card/index.tsx
  65. 1 0
      packages/component-library/src/CrossfadeTabPanels/index.tsx
  66. 94 16
      packages/component-library/src/Drawer/index.module.scss
  67. 179 58
      packages/component-library/src/Drawer/index.tsx
  68. 1 21
      packages/component-library/src/Html/base.scss
  69. 3 52
      packages/component-library/src/Html/index.tsx
  70. 0 1
      packages/component-library/src/InfoBox/index.module.scss
  71. 11 0
      packages/component-library/src/MainContent/index.module.scss
  72. 57 0
      packages/component-library/src/MainContent/index.tsx
  73. 1 0
      packages/component-library/src/MainNavTabs/index.module.scss
  74. 16 2
      packages/component-library/src/ModalDialog/index.tsx
  75. 10 2
      packages/component-library/src/Paginator/index.module.scss
  76. 4 1
      packages/component-library/src/SearchInput/index.module.scss
  77. 3 2
      packages/component-library/src/SearchInput/index.tsx
  78. 1 0
      packages/component-library/src/SingleToggleGroup/index.module.scss
  79. 9 2
      packages/component-library/src/TabList/index.module.scss
  80. 1 0
      packages/component-library/src/Table/index.module.scss
  81. 0 10
      packages/component-library/src/overlay-visible-context.tsx
  82. 36 12
      packages/component-library/src/theme.scss
  83. 3 0
      packages/component-library/src/unstyled/GridList/index.tsx
  84. 6 0
      packages/component-library/stylelint.config.js
  85. 4 0
      packages/next-root/scss.d.ts
  86. 4 1
      packages/next-root/src/index.tsx

+ 2 - 0
.github/workflows/ci-turbo-build.yml

@@ -15,6 +15,8 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
+      - name: Install libusb
+        run: sudo apt install -y libusb-1.0-0-dev
       - uses: actions/checkout@v4
       - uses: Swatinem/rust-cache@v2
       - uses: actions/setup-node@v4

+ 2 - 0
.github/workflows/ci-turbo-test.yml

@@ -15,6 +15,8 @@ jobs:
   test:
     runs-on: ubuntu-latest
     steps:
+      - name: Install libusb
+        run: sudo apt install -y libusb-1.0-0-dev
       - uses: actions/checkout@v4
       - uses: actions/setup-node@v4
         with:

+ 2 - 0
.github/workflows/publish-js.yml

@@ -9,6 +9,8 @@ jobs:
     name: Publish Javascript Packages to NPM
     runs-on: ubuntu-latest
     steps:
+      - name: Install libusb
+        run: sudo apt install -y libusb-1.0-0-dev
       - uses: actions/checkout@v2
       - uses: actions/setup-node@v4
         with:

+ 40 - 0
apps/insights/src/components/Cards/index.module.scss

@@ -0,0 +1,40 @@
+@use "@pythnetwork/component-library/theme";
+
+.cards {
+  display: flex;
+  flex-flow: row nowrap;
+  align-items: stretch;
+  gap: theme.spacing(6);
+  overflow-x: auto;
+  margin-left: calc(-1 * #{theme.$max-width-padding});
+  margin-right: calc(-1 * #{theme.$max-width-padding});
+  padding: theme.spacing(4) theme.$max-width-padding theme.spacing(4)
+    theme.$max-width-padding;
+  scroll-snap-type: x mandatory;
+  scroll-padding-inline: theme.$max-width-padding;
+
+  @include theme.breakpoint("sm") {
+    padding-top: theme.spacing(6);
+    padding-bottom: theme.spacing(6);
+  }
+
+  & > * {
+    flex: none;
+    width: 70vw;
+    max-width: theme.spacing(70);
+    scroll-snap-align: start;
+
+    @include theme.breakpoint("sm") {
+      flex: 1 0 theme.spacing(70);
+      width: theme.spacing(70);
+      max-width: unset;
+    }
+  }
+
+  .publishersChart,
+  .priceFeedsChart {
+    & svg {
+      cursor: pointer;
+    }
+  }
+}

+ 8 - 0
apps/insights/src/components/Cards/index.tsx

@@ -0,0 +1,8 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+
+export const Cards = ({ className, ...props }: ComponentProps<"section">) => (
+  <section className={clsx(className, styles.cards)} {...props} />
+);

+ 84 - 0
apps/insights/src/components/EntityList/index.module.scss

@@ -0,0 +1,84 @@
+@use "@pythnetwork/component-library/theme";
+
+.entityList {
+  background: theme.color("background", "primary");
+  border-radius: theme.border-radius("xl");
+  list-style-type: none;
+  padding: 0;
+  margin: 0;
+
+  .entityItem {
+    padding: theme.spacing(3) theme.spacing(4);
+    border-bottom: 1px solid theme.color("background", "secondary");
+    outline: theme.spacing(0.5) solid transparent;
+    outline-offset: -#{theme.spacing(0.5)};
+    transition:
+      outline-color 100ms linear,
+      background-color 100ms linear;
+    -webkit-tap-highlight-color: transparent;
+
+    &[data-focus-visible] {
+      outline: theme.spacing(0.5) solid theme.color("focus");
+    }
+
+    &[data-href] {
+      cursor: pointer;
+    }
+
+    &[data-hovered] {
+      background-color: theme.color("button", "outline", "background", "hover");
+    }
+
+    &[data-pressed] {
+      background-color: theme.color(
+        "button",
+        "outline",
+        "background",
+        "active"
+      );
+    }
+
+    &:first-child {
+      border-top-left-radius: theme.border-radius("xl");
+      border-top-right-radius: theme.border-radius("xl");
+    }
+
+    &:last-child {
+      border-bottom-left-radius: theme.border-radius("xl");
+      border-bottom-right-radius: theme.border-radius("xl");
+      border-bottom: none;
+    }
+
+    .itemHeader,
+    .itemDetailsItem {
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .itemDetails {
+      display: grid;
+      grid-template-columns: 1fr;
+      gap: theme.spacing(2) theme.spacing(18);
+
+      @include theme.breakpoint("sm") {
+        grid-template-columns: repeat(2, 1fr);
+      }
+
+      .itemDetailsItem {
+        height: theme.spacing(5);
+
+        dt {
+          @include theme.text("sm", "normal");
+
+          color: theme.color("muted");
+        }
+
+        dd {
+          margin: 0;
+        }
+      }
+    }
+  }
+}

+ 97 - 0
apps/insights/src/components/EntityList/index.tsx

@@ -0,0 +1,97 @@
+"use client";
+
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import {
+  GridList,
+  GridListItem,
+} from "@pythnetwork/component-library/unstyled/GridList";
+import clsx from "clsx";
+import type { ComponentProps, ReactNode } from "react";
+
+import styles from "./index.module.scss";
+
+type Props<T extends string> = ComponentProps<typeof GridList<RowConfig<T>>> & {
+  headerLoadingSkeleton?: ReactNode | undefined;
+  label: string;
+  fields: ({
+    id: T;
+    name: ReactNode;
+  } & (
+    | { loadingSkeleton?: ReactNode | undefined }
+    | { loadingSkeletonWidth?: number | undefined }
+  ))[];
+} & (
+    | {
+        isLoading: true;
+        rows?: RowConfig<T>[] | undefined;
+      }
+    | {
+        isLoading?: false | undefined;
+        rows: RowConfig<T>[];
+      }
+  );
+
+type RowConfig<T extends string> = {
+  id: string | number;
+  data: Record<T, ReactNode>;
+  header: ReactNode;
+  href?: string;
+  textValue: string;
+};
+
+export const EntityList = <T extends string>({
+  fields,
+  isLoading,
+  rows,
+  headerLoadingSkeleton,
+  className,
+  label,
+  ...props
+}: Props<T>) => (
+  <GridList
+    className={clsx(styles.entityList, className)}
+    items={isLoading ? [] : rows}
+    aria-label={label}
+    {...props}
+  >
+    {isLoading ? (
+      <GridListItem className={styles.entityItem ?? ""}>
+        <div className={styles.itemHeader}>{headerLoadingSkeleton}</div>
+        <dl className={styles.itemDetails}>
+          {fields.map((field) => (
+            <div key={field.id} className={styles.itemDetailsItem}>
+              <dt>{field.name}</dt>
+              <dd>
+                {"loadingSkeleton" in field ? (
+                  field.loadingSkeleton
+                ) : (
+                  <Skeleton
+                    width={
+                      "loadingSkeletonWidth" in field
+                        ? field.loadingSkeletonWidth
+                        : 20
+                    }
+                  />
+                )}
+              </dd>
+            </div>
+          ))}
+        </dl>
+      </GridListItem>
+    ) : (
+      ({ data, header, ...props }) => (
+        <GridListItem className={styles.entityItem ?? ""} {...props}>
+          <div className={styles.itemHeader}>{header}</div>
+          <dl className={styles.itemDetails}>
+            {fields.map((field) => (
+              <div key={field.id} className={styles.itemDetailsItem}>
+                <dt>{field.name}</dt>
+                <dd>{data[field.id]}</dd>
+              </div>
+            ))}
+          </dl>
+        </GridListItem>
+      )
+    )}
+  </GridList>
+);

+ 21 - 5
apps/insights/src/components/Error/index.module.scss

@@ -1,19 +1,35 @@
 @use "@pythnetwork/component-library/theme";
 
 .error {
-  @include theme.max-width;
-
   display: flex;
   flex-flow: column nowrap;
   gap: theme.spacing(12);
   align-items: center;
   text-align: center;
-  padding: theme.spacing(36) theme.spacing(0);
+  padding-top: theme.spacing(8);
+  padding-bottom: theme.spacing(8);
+
+  @include theme.max-width;
+
+  @include theme.breakpoint("sm") {
+    padding-top: theme.spacing(18);
+    padding-bottom: theme.spacing(18);
+  }
+
+  @include theme.breakpoint("lg") {
+    padding-top: theme.spacing(36);
+    padding-bottom: theme.spacing(36);
+  }
 
   .errorIcon {
-    font-size: theme.spacing(20);
-    height: theme.spacing(20);
+    font-size: theme.spacing(14);
+    height: theme.spacing(14);
     color: theme.color("states", "error", "color");
+
+    @include theme.breakpoint("sm") {
+      font-size: theme.spacing(20);
+      height: theme.spacing(20);
+    }
   }
 
   .text {

+ 12 - 4
apps/insights/src/components/Explain/index.module.scss

@@ -1,9 +1,17 @@
 @use "@pythnetwork/component-library/theme";
 
-.trigger {
-  @each $size, $values in theme.$button-sizes {
-    &[data-size="#{$size}"] {
-      margin: -#{theme.map-get-strict($values, "padding")};
+.explain {
+  display: none;
+
+  @include theme.breakpoint("sm") {
+    display: grid;
+  }
+
+  .trigger {
+    @each $size, $values in theme.$button-sizes {
+      &[data-size="#{$size}"] {
+        margin: -#{theme.map-get-strict($values, "padding")};
+      }
     }
   }
 }

+ 21 - 19
apps/insights/src/components/Explain/index.tsx

@@ -13,23 +13,25 @@ type Props = {
 };
 
 export const Explain = ({ size, title, children }: Props) => (
-  <AlertTrigger>
-    <Button
-      className={styles.trigger ?? ""}
-      variant="ghost"
-      size={size}
-      beforeIcon={(props) => <Info weight="fill" {...props} />}
-      rounded
-      hideText
-    >
-      Explain {title}
-    </Button>
-    <Alert
-      title={title}
-      icon={<Lightbulb />}
-      bodyClassName={styles.description}
-    >
-      {children}
-    </Alert>
-  </AlertTrigger>
+  <div className={styles.explain}>
+    <AlertTrigger>
+      <Button
+        className={styles.trigger ?? ""}
+        variant="ghost"
+        size={size}
+        beforeIcon={(props) => <Info weight="fill" {...props} />}
+        rounded
+        hideText
+      >
+        Explain {title}
+      </Button>
+      <Alert
+        title={title}
+        icon={<Lightbulb />}
+        bodyClassName={styles.description}
+      >
+        {children}
+      </Alert>
+    </AlertTrigger>
+  </div>
 );

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

@@ -16,7 +16,7 @@ type Props = {
     }
 );
 
-type Variant = "success" | "error" | "warning" | "info" | "data";
+export type Variant = "success" | "error" | "warning" | "info" | "data";
 
 export const NoResults = ({ onClearSearch, ...props }: Props) => (
   <div

+ 21 - 5
apps/insights/src/components/NotFound/index.module.scss

@@ -1,23 +1,39 @@
 @use "@pythnetwork/component-library/theme";
 
 .notFound {
-  @include theme.max-width;
-
   display: flex;
   flex-flow: column nowrap;
   gap: theme.spacing(12);
   align-items: center;
   text-align: center;
-  padding: theme.spacing(36) theme.spacing(0);
+  padding-top: theme.spacing(8);
+  padding-bottom: theme.spacing(8);
+
+  @include theme.max-width;
+
+  @include theme.breakpoint("sm") {
+    padding-top: theme.spacing(18);
+    padding-bottom: theme.spacing(18);
+  }
+
+  @include theme.breakpoint("lg") {
+    padding-top: theme.spacing(36);
+    padding-bottom: theme.spacing(36);
+  }
 
   .searchIcon {
     display: grid;
     place-content: center;
-    padding: theme.spacing(8);
+    padding: theme.spacing(4);
     background: theme.color("button", "disabled", "background");
-    font-size: theme.spacing(12);
+    font-size: theme.spacing(8);
     color: theme.color("button", "disabled", "foreground");
     border-radius: theme.border-radius("full");
+
+    @include theme.breakpoint("sm") {
+      padding: theme.spacing(8);
+      font-size: theme.spacing(12);
+    }
   }
 
   .text {

+ 70 - 44
apps/insights/src/components/Overview/index.module.scss

@@ -4,71 +4,97 @@
   @include theme.max-width;
 
   .header {
-    @include theme.h3;
-
     color: theme.color("heading");
-    font-weight: theme.font-weight("semibold");
-    margin-bottom: theme.spacing(6);
-  }
 
-  .stats {
-    display: flex;
-    flex-flow: row nowrap;
-    align-items: stretch;
-    gap: theme.spacing(6);
-
-    & > * {
-      flex: 1 1 0px;
-      width: 0;
-    }
-
-    .publishersChart,
-    .priceFeedsChart {
-      & svg {
-        cursor: pointer;
-      }
-    }
+    @include theme.h3;
   }
 
   .overviewMainContent {
     display: grid;
-    grid-template-columns: repeat(2, 1fr);
-    gap: theme.spacing(40);
     align-items: center;
-    padding: theme.spacing(18) 0;
+    padding-top: theme.spacing(6);
+    padding-bottom: theme.spacing(30);
 
-    .headline {
-      @include theme.text("3xl", "medium");
+    @include theme.breakpoint("md") {
+      grid-template-columns: repeat(2, 1fr);
+      column-gap: theme.spacing(20);
+      padding-top: theme.spacing(12);
+    }
 
-      color: theme.color("heading");
-      line-height: 125%;
-      margin-top: theme.spacing(8);
-      margin-bottom: theme.spacing(4);
+    @include theme.breakpoint("xl") {
+      column-gap: theme.spacing(40);
     }
 
-    .message {
-      @include theme.text("base", "normal");
+    .intro {
+      margin-bottom: theme.spacing(6);
+
+      .headline {
+        @include theme.text("3xl", "medium");
+
+        color: theme.color("heading");
+        line-height: 125%;
+        margin-top: theme.spacing(8);
+        margin-bottom: theme.spacing(4);
+      }
+
+      .message {
+        @include theme.text("base", "normal");
 
-      color: theme.color("heading");
-      line-height: 150%;
+        color: theme.color("heading");
+        line-height: 150%;
+      }
     }
 
     .tabList {
-      margin: theme.spacing(12) 0;
+      margin: theme.spacing(6) 0;
+
+      @include theme.breakpoint("md") {
+        margin: theme.spacing(12) 0;
+        grid-column: 1;
+        grid-row: 2;
+      }
+    }
+
+    .imagePanel {
+      display: flex;
+      place-content: center;
+
+      @include theme.breakpoint("md") {
+        grid-row: span 3 / span 3;
+        grid-column: 2;
+      }
+
+      .darkImage,
+      .lightImage {
+        max-height: theme.spacing(80);
+
+        @include theme.breakpoint("md") {
+          max-height: theme.spacing(120);
+        }
+      }
+
+      .lightImage {
+        @at-root html[data-theme="dark"] & {
+          display: none;
+        }
+      }
+
+      .darkImage {
+        @at-root html[data-theme="light"] & {
+          display: none;
+        }
+      }
     }
 
     .buttons {
       display: flex;
       flex-flow: row nowrap;
       gap: theme.spacing(3);
+
+      @include theme.breakpoint("md") {
+        grid-column: 1;
+        grid-row: 3;
+      }
     }
   }
 }
-
-html[data-theme="dark"] .lightImage {
-  display: none;
-}
-
-html[data-theme="light"] .darkImage {
-  display: none;
-}

+ 31 - 28
apps/insights/src/components/Overview/index.tsx

@@ -15,6 +15,7 @@ import {
   activePublishers,
   activeFeeds,
 } from "../../static-data/stats";
+import { Cards } from "../Cards";
 import { ChangePercent } from "../ChangePercent";
 import { ChartCard } from "../ChartCard";
 import { FormattedDate } from "../FormattedDate";
@@ -23,7 +24,7 @@ import { FormattedNumber } from "../FormattedNumber";
 export const Overview = () => (
   <div className={styles.overview}>
     <h1 className={styles.header}>Overview</h1>
-    <section className={styles.stats}>
+    <Cards>
       <ChartCard
         header="Total Volume Traded"
         variant="primary"
@@ -104,44 +105,21 @@ export const Overview = () => (
         }
         stat={activeChains.at(-1)?.chains}
       />
-    </section>
+    </Cards>
     <Tabs orientation="vertical" className={styles.overviewMainContent ?? ""}>
-      <section>
+      <section className={styles.intro}>
         <Badge>INSIGHTS</Badge>
         <p className={styles.headline}>Get the most from the Pyth Network</p>
         <p className={styles.message}>
           Insights Hub delivers transparency over the network status and
-          performance, and maximize productivity while integrating.
+          performance, and maximizes productivity while integrating.
         </p>
-        <TabList
-          label="test"
-          className={styles.tabList ?? ""}
-          items={[
-            {
-              id: "publishers",
-              header: "Publishers",
-              body: "Get insights about quality, ranking, and performance of each Publisher contributing to the network.",
-            },
-            {
-              id: "price feeds",
-              header: "Price Feeds",
-              body: "See information about every price feed's price, performance, components, and technical aspects all in one place for a better integration experience.",
-            },
-          ]}
-        />
-        <div className={styles.buttons}>
-          <Button href="/publishers" variant="solid" size="md">
-            Publishers
-          </Button>
-          <Button href="/price-feeds" variant="outline" size="md">
-            Price Feeds
-          </Button>
-        </div>
       </section>
       <CrossfadeTabPanels
         items={[
           {
             id: "publishers",
+            className: styles.imagePanel ?? "",
             children: (
               <>
                 <PublishersDark className={styles.darkImage} />
@@ -151,6 +129,7 @@ export const Overview = () => (
           },
           {
             id: "price feeds",
+            className: styles.imagePanel ?? "",
             children: (
               <>
                 <PriceFeedsDark className={styles.darkImage} />
@@ -160,6 +139,30 @@ export const Overview = () => (
           },
         ]}
       />
+      <TabList
+        label="test"
+        className={styles.tabList ?? ""}
+        items={[
+          {
+            id: "publishers",
+            header: "Publishers",
+            body: "Get insights about quality, ranking, and performance of each Publisher contributing to the network.",
+          },
+          {
+            id: "price feeds",
+            header: "Price Feeds",
+            body: "See information about every price feed's price, performance, components, and technical aspects all in one place for a better integration experience.",
+          },
+        ]}
+      />
+      <div className={styles.buttons}>
+        <Button href="/publishers" variant="solid" size="md">
+          Publishers
+        </Button>
+        <Button href="/price-feeds" variant="outline" size="md">
+          Price Feeds
+        </Button>
+      </div>
     </Tabs>
   </div>
 );

+ 96 - 3
apps/insights/src/components/PriceComponentDrawer/index.module.scss

@@ -1,17 +1,52 @@
 @use "@pythnetwork/component-library/theme";
 
 .priceComponentDrawer {
+  .badges {
+    @include theme.breakpoint("lg") {
+      display: none;
+    }
+  }
+
+  .ghostOpenButton {
+    @include theme.breakpoint("md") {
+      display: none;
+    }
+  }
+
+  .bigScreenBadges {
+    display: none;
+
+    @include theme.breakpoint("lg") {
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(3);
+      align-items: center;
+    }
+  }
+
+  .outlineOpenButton {
+    display: none;
+
+    @include theme.breakpoint("md") {
+      display: inline flow-root;
+    }
+  }
+
   .testFeedMessage {
-    grid-column: span 2 / span 2;
     margin-bottom: theme.spacing(10);
   }
 
   .stats {
     display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    grid-template-rows: repeat(2, 1fr);
+    grid-template-columns: repeat(2, 1fr);
+    grid-template-rows: repeat(3, 1fr);
     gap: theme.spacing(4);
     margin-bottom: theme.spacing(10);
+
+    @include theme.breakpoint("lg") {
+      grid-template-columns: repeat(3, 1fr);
+      grid-template-rows: repeat(2, 1fr);
+    }
   }
 
   .spinner {
@@ -129,6 +164,64 @@
       margin: theme.spacing(2) theme.spacing(4);
     }
 
+    .smallLegend {
+      list-style-type: none;
+      padding: 0;
+      margin: 0;
+      background: theme.color("background", "primary");
+      border-radius: theme.border-radius("xl");
+
+      li {
+        padding: theme.spacing(3) theme.spacing(4);
+        border-bottom: 1px solid theme.color("background", "secondary");
+
+        &:last-child {
+          border-bottom: none;
+        }
+
+        dl {
+          display: flex;
+          flex-flow: row nowrap;
+          justify-content: space-between;
+          font-size: theme.font-size("sm");
+          margin: 0;
+          margin-top: theme.spacing(4);
+
+          .weight,
+          .scoreValue {
+            display: flex;
+            flex-flow: row nowrap;
+            gap: theme.spacing(2);
+
+            dt {
+              font-weight: theme.font-weight("medium");
+            }
+
+            dd {
+              padding: 0;
+              margin: 0;
+            }
+          }
+        }
+      }
+
+      .metricDescription {
+        display: none;
+      }
+
+      @include theme.breakpoint("lg") {
+        display: none;
+      }
+    }
+
+    .legendTable {
+      display: none;
+
+      @include theme.breakpoint("lg") {
+        display: unset;
+      }
+    }
+
     .scoreCell {
       vertical-align: top;
     }

+ 99 - 4
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -1,3 +1,4 @@
+import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
 import { Flask } from "@phosphor-icons/react/dist/ssr/Flask";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
@@ -113,17 +114,41 @@ export const PriceComponentDrawer = ({
       title={title}
       headingExtra={
         <>
-          {headingExtra}
-          <StatusComponent status={status} />
+          <div className={styles.bigScreenBadges}>
+            {headingExtra}
+            <StatusComponent status={status} />
+          </div>
           <RouterProvider navigate={handleOpenFeed}>
-            <Button size="sm" variant="outline" href={navigateHref}>
+            <Button
+              size="sm"
+              variant="ghost"
+              href={navigateHref}
+              hideText
+              beforeIcon={ArrowSquareOut}
+              rounded
+              className={styles.ghostOpenButton ?? ""}
+            >
+              Open {identifiesPublisher ? "Publisher" : "Feed"}
+            </Button>
+            <Button
+              size="sm"
+              variant="outline"
+              href={navigateHref}
+              className={styles.outlineOpenButton ?? ""}
+            >
               Open {identifiesPublisher ? "Publisher" : "Feed"}
             </Button>
           </RouterProvider>
         </>
       }
+      headingAfter={
+        <div className={styles.badges}>
+          {headingExtra}
+          <StatusComponent status={status} />
+        </div>
+      }
       isOpen={isFeedDrawerOpen}
-      bodyClassName={styles.priceComponentDrawer}
+      className={styles.priceComponentDrawer ?? ""}
     >
       {cluster === Cluster.PythtestConformance && (
         <InfoBox
@@ -491,10 +516,80 @@ const ResolvedScoreHistory = ({ scoreHistory }: ResolvedScoreHistoryProps) => {
         Score details for{" "}
         {currentPoint && dateFormatter.format(currentPoint.time)}
       </h3>
+      <ul className={styles.smallLegend}>
+        <li>
+          <Metric
+            component="uptime"
+            name={SCORE_COMPONENT_TO_LABEL.uptime}
+            description="Percentage of time a publisher is available and active"
+          />
+          <dl>
+            <div className={styles.weight}>
+              <dt>Weight</dt>
+              <dd>40%</dd>
+            </div>
+            <div className={styles.scoreValue}>
+              <dt>Score</dt>
+              <dd>{numberFormatter.format(currentPoint?.uptimeScore ?? 0)}</dd>
+            </div>
+          </dl>
+        </li>
+        <li>
+          <Metric
+            component="deviation"
+            name={SCORE_COMPONENT_TO_LABEL.deviation}
+            description="Deviations that occur between a publishers' price and the aggregate price"
+          />
+          <dl>
+            <div className={styles.weight}>
+              <dt>Weight</dt>
+              <dd>40%</dd>
+            </div>
+            <div className={styles.scoreValue}>
+              <dt>Score</dt>
+              <dd>
+                {numberFormatter.format(currentPoint?.deviationScore ?? 0)}
+              </dd>
+            </div>
+          </dl>
+        </li>
+        <li>
+          <Metric
+            component="stalled"
+            name={SCORE_COMPONENT_TO_LABEL.stalled}
+            description="Penalizes publishers reporting the same value for the price"
+          />
+          <dl>
+            <div className={styles.weight}>
+              <dt>Weight</dt>
+              <dd>20%</dd>
+            </div>
+            <div className={styles.scoreValue}>
+              <dt>Score</dt>
+              <dd>{numberFormatter.format(currentPoint?.stalledScore ?? 0)}</dd>
+            </div>
+          </dl>
+        </li>
+        <li>
+          <Metric
+            component="final"
+            name={SCORE_COMPONENT_TO_LABEL.final}
+            description="The aggregate score, calculated by combining the other three score components"
+          />
+          <dl>
+            <div className={styles.weight}></div>
+            <div className={styles.scoreValue}>
+              <dt>Score</dt>
+              <dd>{numberFormatter.format(currentPoint?.score ?? 0)}</dd>
+            </div>
+          </dl>
+        </li>
+      </ul>
       <Table
         label="Score Breakdown"
         rounded
         fill
+        className={styles.legendTable ?? ""}
         columns={[
           {
             id: "metric",

+ 89 - 0
apps/insights/src/components/PriceComponentsCard/index.module.scss

@@ -0,0 +1,89 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceComponentsCard {
+  .toolbar {
+    width: 100%;
+    display: flex;
+    flex-flow: column-reverse nowrap;
+    gap: theme.spacing(2);
+    padding-bottom: theme.spacing(2);
+
+    @include theme.breakpoint("sm") {
+      align-items: center;
+      flex-flow: row nowrap;
+      padding-bottom: unset;
+    }
+
+    .toolbarSection {
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(2);
+
+      &[data-section="search"] {
+        @include theme.breakpoint("sm") {
+          flex-grow: 1;
+        }
+      }
+
+      &[data-section="mode"] {
+        display: none;
+
+        @include theme.breakpoint("xl") {
+          display: block;
+        }
+      }
+    }
+
+    .searchInput {
+      flex-grow: 1;
+    }
+  }
+
+  .table {
+    display: none;
+
+    @include theme.breakpoint("xl") {
+      display: unset;
+    }
+  }
+
+  .entityList {
+    @include theme.breakpoint("xl") {
+      display: none;
+    }
+
+    @include theme.breakpoint("sm") {
+      dl > *:nth-child(1) {
+        order: 1;
+      }
+
+      dl > *:nth-child(2) {
+        order: 3;
+      }
+
+      dl > *:nth-child(3) {
+        order: 5;
+      }
+
+      dl > *:nth-child(4) {
+        order: 2;
+      }
+
+      dl > *:nth-child(5) {
+        order: 4;
+      }
+
+      dl > *:nth-child(6) {
+        order: 6;
+      }
+
+      dl > *:nth-child(7) {
+        order: 8;
+      }
+
+      dl > *:nth-child(8) {
+        order: 7;
+      }
+    }
+  }
+}

+ 150 - 102
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -14,10 +14,18 @@ import {
   type SortDescriptor,
   Table,
 } from "@pythnetwork/component-library/Table";
+import clsx from "clsx";
 import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs";
-import { type ReactNode, Suspense, useMemo, useCallback } from "react";
+import {
+  type ReactNode,
+  Fragment,
+  Suspense,
+  useMemo,
+  useCallback,
+} from "react";
 import { useFilter, useCollator } from "react-aria";
 
+import styles from "./index.module.scss";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import {
@@ -26,6 +34,7 @@ import {
   Status as StatusType,
   statusNameToStatus,
 } from "../../status";
+import { EntityList } from "../EntityList";
 import { Explain } from "../Explain";
 import { EvaluationTime } from "../Explanations";
 import { FormattedNumber } from "../FormattedNumber";
@@ -181,6 +190,7 @@ export const ResolvedPriceComponentsCard = <
     () =>
       paginatedItems.map((component) => ({
         id: component.id,
+        nameAsString: component.nameAsString,
         data: {
           name: component.name,
           ...Object.fromEntries(
@@ -189,61 +199,56 @@ export const ResolvedPriceComponentsCard = <
               component[column.id],
             ]) ?? [],
           ),
-          ...(showQuality
-            ? {
-                score: component.score !== undefined && (
-                  <Score score={component.score} width={SCORE_WIDTH} />
-                ),
-                uptimeScore: component.uptimeScore !== undefined && (
-                  <FormattedNumber
-                    value={component.uptimeScore}
-                    maximumSignificantDigits={5}
-                  />
-                ),
-                deviationScore: component.deviationScore !== undefined && (
-                  <FormattedNumber
-                    value={component.deviationScore}
-                    maximumSignificantDigits={5}
-                  />
-                ),
-                stalledScore: component.stalledScore !== undefined && (
-                  <FormattedNumber
-                    value={component.stalledScore}
-                    maximumSignificantDigits={5}
-                  />
-                ),
-              }
-            : {
-                slot: (
-                  <LiveComponentValue
-                    feedKey={component.feedKey}
-                    publisherKey={component.publisherKey}
-                    field="publishSlot"
-                    cluster={component.cluster}
-                  />
-                ),
-                price: (
-                  <LivePrice
-                    feedKey={component.feedKey}
-                    publisherKey={component.publisherKey}
-                    cluster={component.cluster}
-                  />
-                ),
-                confidence: (
-                  <LiveConfidence
-                    feedKey={component.feedKey}
-                    publisherKey={component.publisherKey}
-                    cluster={component.cluster}
-                  />
-                ),
-              }),
+          score: component.score !== undefined && (
+            <Score score={component.score} width={SCORE_WIDTH} />
+          ),
+          uptimeScore: component.uptimeScore !== undefined && (
+            <FormattedNumber
+              value={component.uptimeScore}
+              maximumSignificantDigits={5}
+            />
+          ),
+          deviationScore: component.deviationScore !== undefined && (
+            <FormattedNumber
+              value={component.deviationScore}
+              maximumSignificantDigits={5}
+            />
+          ),
+          stalledScore: component.stalledScore !== undefined && (
+            <FormattedNumber
+              value={component.stalledScore}
+              maximumSignificantDigits={5}
+            />
+          ),
+          slot: (
+            <LiveComponentValue
+              feedKey={component.feedKey}
+              publisherKey={component.publisherKey}
+              field="publishSlot"
+              cluster={component.cluster}
+            />
+          ),
+          price: (
+            <LivePrice
+              feedKey={component.feedKey}
+              publisherKey={component.publisherKey}
+              cluster={component.cluster}
+            />
+          ),
+          confidence: (
+            <LiveConfidence
+              feedKey={component.feedKey}
+              publisherKey={component.publisherKey}
+              cluster={component.cluster}
+            />
+          ),
           status: <StatusComponent status={component.status} />,
         },
         onAction: () => {
           onPriceComponentAction(component);
         },
       })),
-    [paginatedItems, showQuality, onPriceComponentAction, props.extraColumns],
+    [paginatedItems, onPriceComponentAction, props.extraColumns],
   );
 
   const updateStatus = useCallback(
@@ -322,7 +327,7 @@ type PriceComponentsCardProps<
         onStatusChange: (newStatus: StatusName | "") => void;
         showQuality: boolean;
         setShowQuality: (newValue: boolean) => void;
-        rows: RowConfig<string>[];
+        rows: (RowConfig<string> & { nameAsString: string })[];
       }
   );
 
@@ -343,7 +348,7 @@ export const PriceComponentsCardContents = <
   const collator = useCollator();
   return (
     <Card
-      className={className}
+      className={clsx(className, styles.priceComponentsCard)}
       title={
         <>
           <span>{label}</span>
@@ -355,56 +360,66 @@ export const PriceComponentsCardContents = <
         </>
       }
       toolbar={
-        <>
-          {toolbarExtra}
-          <Select<StatusName | "">
-            label="Status"
-            size="sm"
-            variant="outline"
-            hideLabel
-            options={[
-              "",
-              ...Object.values(STATUS_NAMES).toSorted((a, b) =>
-                collator.compare(a, b),
-              ),
-            ]}
-            {...(props.isLoading
-              ? { isPending: true, buttonLabel: "Status" }
-              : {
-                  show: (value) => (value === "" ? "All" : value),
-                  placement: "bottom end",
-                  buttonLabel: props.status === "" ? "Status" : props.status,
-                  selectedKey: props.status,
-                  onSelectionChange: props.onStatusChange,
-                })}
-          />
-          <SearchInput
-            size="sm"
-            width={60}
-            placeholder={searchPlaceholder}
-            {...(props.isLoading
-              ? { isPending: true, isDisabled: true }
-              : {
-                  value: props.search,
-                  onChange: props.onSearchChange,
-                })}
-          />
-          <SingleToggleGroup
-            {...(!props.isLoading && {
-              selectedKey: props.showQuality ? "quality" : "prices",
-              onSelectionChange: (newValue) => {
-                props.setShowQuality(newValue === "quality");
-              },
-            })}
-            items={[
-              {
-                id: "prices",
-                children: <PriceName assetClass={props.assetClass} plural />,
-              },
-              { id: "quality", children: "Quality" },
-            ]}
-          />
-        </>
+        <div className={styles.toolbar}>
+          {toolbarExtra && (
+            <div data-section="extra" className={styles.toolbarSection}>
+              {toolbarExtra}
+            </div>
+          )}
+          <div data-section="search" className={styles.toolbarSection}>
+            <Select<StatusName | "">
+              label="Status"
+              size="sm"
+              variant="outline"
+              hideLabel
+              options={[
+                "",
+                ...Object.values(STATUS_NAMES).toSorted((a, b) =>
+                  collator.compare(a, b),
+                ),
+              ]}
+              {...(props.isLoading
+                ? { isPending: true, buttonLabel: "Status" }
+                : {
+                    show: (value) => (value === "" ? "All" : value),
+                    placement: "bottom end",
+                    buttonLabel: props.status === "" ? "Status" : props.status,
+                    selectedKey: props.status,
+                    onSelectionChange: props.onStatusChange,
+                  })}
+            />
+            <SearchInput
+              size="sm"
+              width={60}
+              placeholder={searchPlaceholder}
+              className={styles.searchInput ?? ""}
+              {...(props.isLoading
+                ? { isPending: true, isDisabled: true }
+                : {
+                    value: props.search,
+                    onChange: props.onSearchChange,
+                  })}
+            />
+          </div>
+          <div data-section="mode" className={styles.toolbarSection}>
+            <SingleToggleGroup
+              className={styles.modeSelect ?? ""}
+              {...(!props.isLoading && {
+                selectedKey: props.showQuality ? "quality" : "prices",
+                onSelectionChange: (newValue) => {
+                  props.setShowQuality(newValue === "quality");
+                },
+              })}
+              items={[
+                {
+                  id: "prices",
+                  children: <PriceName assetClass={props.assetClass} plural />,
+                },
+                { id: "quality", children: "Quality" },
+              ]}
+            />
+          </div>
+        </div>
       }
       {...(!props.isLoading && {
         footer: (
@@ -420,11 +435,44 @@ export const PriceComponentsCardContents = <
         ),
       })}
     >
+      <EntityList
+        label={label}
+        className={styles.entityList ?? ""}
+        headerLoadingSkeleton={nameLoadingSkeleton}
+        fields={[
+          { id: "slot", name: "Slot" },
+          { id: "price", name: "Price" },
+          { id: "confidence", name: "Confidence" },
+          { id: "uptimeScore", name: "Uptime Score" },
+          { id: "deviationScore", name: "Deviation Score" },
+          { id: "stalledScore", name: "Stalled Score" },
+          { id: "score", name: "Final Score" },
+          { id: "status", name: "Status" },
+        ]}
+        isLoading={props.isLoading}
+        rows={
+          props.isLoading
+            ? []
+            : props.rows.map((row) => ({
+                ...row,
+                textValue: row.nameAsString,
+                header: (
+                  <>
+                    {row.data.name}
+                    {extraColumns?.map((column) => (
+                      <Fragment key={column.id}>{row.data[column.id]}</Fragment>
+                    ))}
+                  </>
+                ),
+              }))
+        }
+      />
       <Table
         label={label}
         fill
         rounded
         stickyHeader={rootStyles.headerHeight}
+        className={styles.table ?? ""}
         columns={[
           {
             id: "name",

+ 39 - 20
apps/insights/src/components/PriceFeed/layout.module.scss

@@ -2,40 +2,59 @@
 
 .priceFeedLayout {
   .header {
-    @include theme.max-width;
-
-    margin-bottom: theme.spacing(6);
+    margin-bottom: theme.spacing(4);
     display: flex;
     flex-flow: column nowrap;
-    gap: theme.spacing(6);
+    gap: theme.spacing(4);
 
-    .headerRow,
-    .rightGroup,
-    .stats {
-      display: flex;
-      flex-flow: row nowrap;
-      align-items: center;
+    @include theme.max-width;
+
+    @include theme.breakpoint("sm") {
+      margin-bottom: theme.spacing(6);
+      gap: theme.spacing(6);
     }
 
     .headerRow {
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(2);
       justify-content: space-between;
+
+      @include theme.breakpoint("sm") {
+        flex-flow: row nowrap;
+        align-items: center;
+        gap: unset;
+      }
     }
 
     .rightGroup {
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
       gap: theme.spacing(2);
-    }
-
-    .stats {
-      gap: theme.spacing(6);
 
       & > * {
-        flex: 1 1 0px;
+        flex: 1 1 0;
         width: 0;
+
+        @include theme.breakpoint("sm") {
+          flex: unset;
+          width: unset;
+        }
+      }
+    }
+
+    .priceFeedSelect {
+      display: none;
+
+      @include theme.breakpoint("sm") {
+        display: block;
       }
+    }
 
-      .confidenceExplainButton {
-        margin-top: -#{theme.button-padding("xs", false)};
-        margin-right: -#{theme.button-padding("xs", false)};
+    .priceFeedTag {
+      @include theme.breakpoint("sm") {
+        display: none;
       }
     }
   }
@@ -48,9 +67,9 @@
   }
 
   .body {
-    @include theme.max-width;
-
     padding-top: theme.spacing(6);
+
+    @include theme.max-width;
   }
 }
 

+ 18 - 30
apps/insights/src/components/PriceFeed/layout.tsx

@@ -1,7 +1,4 @@
-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 } from "@pythnetwork/component-library/Button";
@@ -14,6 +11,8 @@ import styles from "./layout.module.scss";
 import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
 import { Cluster, getFeeds } from "../../services/pyth";
+import { Cards } from "../Cards";
+import { Explain } from "../Explain";
 import { FeedKey } from "../FeedKey";
 import {
   LivePrice,
@@ -63,9 +62,10 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           </div>
         </div>
         <div className={styles.headerRow}>
-          <PriceFeedSelect>
+          <PriceFeedSelect className={styles.priceFeedSelect}>
             <PriceFeedTag symbol={feed.symbol} />
           </PriceFeedSelect>
+          <PriceFeedTag className={styles.priceFeedTag} symbol={feed.symbol} />
           <div className={styles.rightGroup}>
             <FeedKey
               variant="ghost"
@@ -108,7 +108,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
             </DrawerTrigger>
           </div>
         </div>
-        <section className={styles.stats}>
+        <Cards>
           <StatCard
             variant="primary"
             header={
@@ -132,34 +132,22 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
               />
             }
             corner={
-              <AlertTrigger>
+              <Explain size="xs" title="Confidence">
+                <p>
+                  <b>Confidence</b> is how far from the aggregate price Pyth
+                  believes the true price might be. It reflects a combination of
+                  the confidence of individual quoters and how well individual
+                  quoters agree with each other.
+                </p>
                 <Button
-                  variant="ghost"
                   size="xs"
-                  beforeIcon={(props) => <Info weight="fill" {...props} />}
-                  rounded
-                  hideText
-                  className={styles.confidenceExplainButton ?? ""}
+                  variant="solid"
+                  href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
+                  target="_blank"
                 >
-                  Explain Confidence
+                  Learn more
                 </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>
-                  <Button
-                    size="xs"
-                    variant="solid"
-                    href="https://docs.pyth.network/price-feeds/best-practices#confidence-intervals"
-                    target="_blank"
-                  >
-                    Learn more
-                  </Button>
-                </Alert>
-              </AlertTrigger>
+              </Explain>
             }
           />
           <StatCard
@@ -185,7 +173,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
               />
             }
           />
-        </section>
+        </Cards>
       </section>
       <TabRoot>
         <Tabs

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

@@ -17,6 +17,7 @@
     transition-duration: 100ms;
     transition-timing-function: linear;
     text-align: left;
+    -webkit-tap-highlight-color: transparent;
 
     .caret {
       width: theme.spacing(8);
@@ -124,6 +125,7 @@
         outline: none;
         text-decoration: none;
         border-top: 1px solid theme.color("background", "secondary");
+        -webkit-tap-highlight-color: transparent;
 
         &[data-is-first] {
           border-top: none;

+ 4 - 2
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -15,6 +15,7 @@ import { Popover } from "@pythnetwork/component-library/unstyled/Popover";
 import { SearchField } from "@pythnetwork/component-library/unstyled/SearchField";
 import { Select } from "@pythnetwork/component-library/unstyled/Select";
 import { Input } from "@pythnetwork/component-library/unstyled/TextField";
+import clsx from "clsx";
 import { type ReactNode, useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
@@ -25,10 +26,11 @@ import { AssetClassTag } from "../AssetClassTag";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
+  className: string | undefined;
   children: ReactNode;
 };
 
-export const PriceFeedSelect = ({ children }: Props) => {
+export const PriceFeedSelect = ({ children, className }: Props) => {
   const feeds = usePriceFeeds();
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
@@ -61,7 +63,7 @@ export const PriceFeedSelect = ({ children }: Props) => {
   return (
     <Select
       aria-label="Select a Price Feed"
-      className={styles.priceFeedSelect ?? ""}
+      className={clsx(className, styles.priceFeedSelect)}
     >
       <Button className={styles.trigger ?? ""}>
         {children}

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

@@ -1,6 +1,15 @@
 @use "@pythnetwork/component-library/theme";
 
 .referenceData {
+  .field,
+  .value {
+    font-size: theme.font-size("xs");
+
+    @include theme.breakpoint("sm") {
+      font-size: theme.font-size("sm");
+    }
+  }
+
   .field {
     color: theme.color("muted");
     font-weight: theme.font-weight("normal");

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

@@ -61,9 +61,9 @@ export const ReferenceData = ({ feed }: Props) => {
         }),
         ...Object.entries({
           Exponent: "exponent",
-          "Number of Price Components": "numComponentPrices",
-          "Number of Price Quoters": "numQuoters",
-          "Minimum Number of Publishers": "minPublishers",
+          "Price Components": "numComponentPrices",
+          "Price Quoters": "numQuoters",
+          "Minimum Publishers": "minPublishers",
           "Last Slot": "lastSlot",
           "Valid Slot": "validSlot",
         } as const).map(

+ 65 - 43
apps/insights/src/components/PriceFeeds/index.module.scss

@@ -1,67 +1,89 @@
 @use "@pythnetwork/component-library/theme";
 
 .priceFeeds {
-  @include theme.max-width;
-
   .header {
+    color: theme.color("heading");
+
     @include theme.h3;
+    @include theme.max-width;
+  }
 
-    color: theme.color("heading");
-    font-weight: theme.font-weight("semibold");
+  .cards {
+    @include theme.max-width;
   }
 
-  .body {
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(6);
-    margin-top: theme.spacing(6);
+  .featuredFeedsCard {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: theme.spacing(1);
 
-    .feedKey {
-      margin: 0 -#{theme.button-padding("xs", true)};
+    @include theme.breakpoint("sm") {
+      grid-template-columns: repeat(2, 1fr);
     }
 
-    .featuredFeeds,
-    .stats {
-      display: flex;
-      flex-flow: row nowrap;
-      align-items: center;
+    @include theme.breakpoint("md") {
+      grid-template-columns: repeat(3, 1fr);
+    }
 
-      & > * {
-        flex: 1 1 0px;
-        width: 0;
-      }
+    @include theme.breakpoint("lg") {
+      grid-template-columns: repeat(5, 1fr);
     }
 
-    .stats {
-      gap: theme.spacing(6);
+    & > * {
+      min-width: 0;
     }
 
-    .featuredFeeds {
-      gap: theme.spacing(1);
+    .feedCardContents {
+      display: flex;
+      flex-flow: column nowrap;
+      justify-content: space-between;
+      align-items: stretch;
+      padding: theme.spacing(3);
+      gap: theme.spacing(6);
 
-      .feedCardContents {
+      .prices {
         display: flex;
-        flex-flow: column nowrap;
+        flex-flow: row nowrap;
         justify-content: space-between;
-        align-items: stretch;
-        padding: theme.spacing(3);
-        gap: theme.spacing(6);
-
-        .prices {
-          display: flex;
-          flex-flow: row nowrap;
-          justify-content: space-between;
-          align-items: center;
-          color: theme.color("heading");
-          font-weight: theme.font-weight("medium");
-          line-height: 1;
-          font-size: theme.font-size("base");
-
-          .changePercent {
-            font-size: theme.font-size("sm");
-          }
+        align-items: center;
+        color: theme.color("heading");
+        font-weight: theme.font-weight("medium");
+        line-height: 1;
+        font-size: theme.font-size("base");
+
+        .changePercent {
+          font-size: theme.font-size("sm");
         }
       }
     }
   }
+
+  .bigScreenBody {
+    display: none;
+    flex-flow: column nowrap;
+    gap: theme.spacing(6);
+    margin-top: theme.spacing(6);
+    width: 100%;
+
+    @include theme.max-width;
+
+    @include theme.breakpoint("lg") {
+      display: flex;
+    }
+  }
+
+  .smallScreenBody {
+    @include theme.breakpoint("lg") {
+      display: none;
+    }
+
+    .tabPanel {
+      padding: theme.spacing(4) 0;
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(6);
+
+      @include theme.max-width;
+    }
+  }
 }

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

@@ -11,6 +11,11 @@ import {
 } from "@pythnetwork/component-library/Card";
 import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { TabList } from "@pythnetwork/component-library/TabList";
+import {
+  TabPanel as UnstyledTabPanel,
+  Tabs as UnstyledTabs,
+} from "@pythnetwork/component-library/unstyled/Tabs";
 import type { ElementType } from "react";
 
 import { AssetClassesDrawer } from "./asset-classes-drawer";
@@ -20,6 +25,7 @@ import { PriceFeedsCard } from "./price-feeds-card";
 import { Cluster, getFeeds } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { activeChains } from "../../static-data/stats";
+import { Cards } from "../Cards";
 import { LivePrice } from "../LivePrices";
 import {
   YesterdaysPricesProvider,
@@ -41,7 +47,7 @@ export const PriceFeeds = async () => {
       ({ symbol }) =>
         !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
     ),
-  ].slice(0, 5);
+  ].slice(0, 6);
   const featuredRecentlyAdded = filterFeeds(
     priceFeeds.activeFeeds,
     priceFeedsStaticConfig.featuredRecentlyAdded,
@@ -50,77 +56,38 @@ export const PriceFeeds = async () => {
   return (
     <div className={styles.priceFeeds}>
       <h1 className={styles.header}>Price Feeds</h1>
-      <div className={styles.body}>
-        <section className={styles.stats}>
-          <StatCard
-            variant="primary"
-            header="Active Feeds"
-            stat={priceFeeds.activeFeeds.length}
-            href={`#${PRICE_FEEDS_ANCHOR}`}
-            corner={<ArrowLineDown />}
-          />
-          <StatCard
-            header="Frequency"
-            stat={priceFeedsStaticConfig.updateFrequency}
-          />
+      <Cards className={styles.cards}>
+        <StatCard
+          variant="primary"
+          header="Active Feeds"
+          stat={priceFeeds.activeFeeds.length}
+          href={`#${PRICE_FEEDS_ANCHOR}`}
+          corner={<ArrowLineDown />}
+        />
+        <StatCard
+          header="Frequency"
+          stat={priceFeedsStaticConfig.updateFrequency}
+        />
+        <StatCard
+          header="Active Chains"
+          stat={activeChains.at(-1)?.chains}
+          href="https://docs.pyth.network/price-feeds/contract-addresses"
+          target="_blank"
+          corner={<ArrowSquareOut weight="fill" />}
+        />
+        <AssetClassesDrawer numFeedsByAssetClass={numFeedsByAssetClass}>
           <StatCard
-            header="Active Chains"
-            stat={activeChains.at(-1)?.chains}
-            href="https://docs.pyth.network/price-feeds/contract-addresses"
-            target="_blank"
-            corner={<ArrowSquareOut weight="fill" />}
-          />
-          <AssetClassesDrawer numFeedsByAssetClass={numFeedsByAssetClass}>
-            <StatCard
-              header="Asset Classes"
-              stat={Object.keys(numFeedsByAssetClass).length}
-              corner={<ArrowsOutSimple />}
-            />
-          </AssetClassesDrawer>
-        </section>
-        <YesterdaysPricesProvider
-          feeds={Object.fromEntries(
-            featuredRecentlyAdded.map(({ symbol, product }) => [
-              symbol,
-              product.price_account,
-            ]),
-          )}
-        >
-          <FeaturedFeedsCard
-            title="Recently Added"
-            icon={<StackPlus />}
-            feeds={featuredRecentlyAdded}
-            showPrices
-            linkFeeds
+            header="Asset Classes"
+            stat={Object.keys(numFeedsByAssetClass).length}
+            corner={<ArrowsOutSimple />}
           />
-        </YesterdaysPricesProvider>
-        <FeaturedFeedsCard
-          title="Coming Soon"
-          icon={<ClockCountdown />}
-          feeds={featuredComingSoon}
-          toolbar={
-            <DrawerTrigger>
-              <Button size="xs" variant="outline">
-                Show all
-              </Button>
-              <Drawer
-                fill
-                className={styles.comingSoonCard ?? ""}
-                title={
-                  <>
-                    <span>Coming Soon</span>
-                    <Badge>{priceFeeds.comingSoon.length}</Badge>
-                  </>
-                }
-              >
-                <ComingSoonList
-                  comingSoonSymbols={priceFeeds.comingSoon.map(
-                    ({ symbol }) => symbol,
-                  )}
-                />
-              </Drawer>
-            </DrawerTrigger>
-          }
+        </AssetClassesDrawer>
+      </Cards>
+      <section className={styles.bigScreenBody}>
+        <FeaturedFeeds
+          allComingSoon={priceFeeds.comingSoon}
+          featuredComingSoon={featuredComingSoon.slice(0, 5)}
+          featuredRecentlyAdded={featuredRecentlyAdded.slice(0, 5)}
         />
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
@@ -130,25 +97,111 @@ export const PriceFeeds = async () => {
             numQuoters: feed.price.numQuoters,
           }))}
         />
-      </div>
+      </section>
+      <UnstyledTabs className={styles.smallScreenBody ?? ""}>
+        <TabList
+          label="Price Feeds Navigation"
+          items={[
+            { children: "Price Feeds", id: "feeds" },
+            { children: "Highlights", id: "highlights" },
+          ]}
+        />
+        <UnstyledTabPanel id="feeds" className={styles.tabPanel ?? ""}>
+          <PriceFeedsCard
+            id={PRICE_FEEDS_ANCHOR}
+            priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
+              symbol: feed.symbol,
+              exponent: feed.price.exponent,
+              numQuoters: feed.price.numQuoters,
+            }))}
+          />
+        </UnstyledTabPanel>
+        <UnstyledTabPanel id="highlights" className={styles.tabPanel ?? ""}>
+          <FeaturedFeeds
+            allComingSoon={priceFeeds.comingSoon}
+            featuredComingSoon={featuredComingSoon}
+            featuredRecentlyAdded={featuredRecentlyAdded}
+          />
+        </UnstyledTabPanel>
+      </UnstyledTabs>
     </div>
   );
 };
 
+type FeaturedFeedsProps = {
+  featuredRecentlyAdded: FeaturedFeed[];
+  featuredComingSoon: FeaturedFeed[];
+  allComingSoon: { symbol: string }[];
+};
+
+const FeaturedFeeds = ({
+  featuredRecentlyAdded,
+  featuredComingSoon,
+  allComingSoon,
+}: FeaturedFeedsProps) => (
+  <>
+    <YesterdaysPricesProvider
+      feeds={Object.fromEntries(
+        featuredRecentlyAdded.map(({ symbol, product }) => [
+          symbol,
+          product.price_account,
+        ]),
+      )}
+    >
+      <FeaturedFeedsCard
+        title="Recently Added"
+        icon={<StackPlus />}
+        feeds={featuredRecentlyAdded}
+        showPrices
+        linkFeeds
+      />
+    </YesterdaysPricesProvider>
+    <FeaturedFeedsCard
+      title="Coming Soon"
+      icon={<ClockCountdown />}
+      feeds={featuredComingSoon}
+      toolbarAlwaysOnTop
+      toolbar={
+        <DrawerTrigger>
+          <Button size="xs" variant="outline">
+            Show all
+          </Button>
+          <Drawer
+            fill
+            className={styles.comingSoonCard ?? ""}
+            title={
+              <>
+                <span>Coming Soon</span>
+                <Badge>{allComingSoon.length}</Badge>
+              </>
+            }
+          >
+            <ComingSoonList
+              comingSoonSymbols={allComingSoon.map(({ symbol }) => symbol)}
+            />
+          </Drawer>
+        </DrawerTrigger>
+      }
+    />
+  </>
+);
+
 type FeaturedFeedsCardProps<T extends ElementType> = Omit<
   CardProps<T>,
   "children"
 > & {
   showPrices?: boolean | undefined;
   linkFeeds?: boolean | undefined;
-  feeds: {
-    symbol: string;
-    product: {
-      display_symbol: string;
-      price_account: string;
-      description: string;
-    };
-  }[];
+  feeds: FeaturedFeed[];
+};
+
+type FeaturedFeed = {
+  symbol: string;
+  product: {
+    display_symbol: string;
+    price_account: string;
+    description: string;
+  };
 };
 
 const FeaturedFeedsCard = <T extends ElementType>({
@@ -158,7 +211,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
   ...props
 }: FeaturedFeedsCardProps<T>) => (
   <Card {...props}>
-    <div className={styles.featuredFeeds}>
+    <div className={styles.featuredFeedsCard}>
       {feeds.map((feed) => (
         <Card
           key={feed.product.price_account}

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

@@ -0,0 +1,27 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceFeedsCard {
+  .toolbar {
+    .searchInput {
+      flex-grow: 1;
+    }
+  }
+
+  .table {
+    display: none;
+
+    @include theme.breakpoint("2xl") {
+      display: unset;
+    }
+  }
+
+  .entityList {
+    @include theme.breakpoint("2xl") {
+      display: none;
+    }
+  }
+
+  .feedKey {
+    margin: -#{theme.button-padding("sm", false)};
+  }
+}

+ 85 - 51
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -16,10 +16,12 @@ import { useQueryState, parseAsString } from "nuqs";
 import { Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
+import styles from "./price-feeds-card.module.scss";
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import { AssetClassTag } from "../AssetClassTag";
+import { EntityList } from "../EntityList";
 import { FeedKey } from "../FeedKey";
 import {
   SKELETON_WIDTH,
@@ -99,15 +101,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
     mkPageLink,
   } = useQueryParamFilterPagination(
     feedsFilteredByAssetClass,
-    (priceFeed, search) => {
-      const searchTokens = search
-        .split(" ")
-        .flatMap((item) => item.split(","))
-        .filter(Boolean);
-      return searchTokens.some((token) =>
-        filter.contains(priceFeed.displaySymbol, token),
-      );
-    },
+    (priceFeed, search) => filter.contains(priceFeed.displaySymbol, search),
     (a, b, { column, direction }) => {
       const field = column === "assetClass" ? "assetClass" : "displaySymbol";
       return (
@@ -120,35 +114,45 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const rows = useMemo(
     () =>
-      paginatedItems.map(({ symbol, exponent, numQuoters, key }) => ({
-        id: symbol,
-        href: `/price-feeds/${encodeURIComponent(symbol)}`,
-        data: {
-          exponent: (
-            <LiveValue
-              field="exponent"
-              feedKey={key}
-              defaultValue={exponent}
-              cluster={Cluster.Pythnet}
-            />
-          ),
-          numPublishers: (
-            <LiveValue
-              field="numQuoters"
-              feedKey={key}
-              defaultValue={numQuoters}
-              cluster={Cluster.Pythnet}
-            />
-          ),
-          price: <LivePrice feedKey={key} cluster={Cluster.Pythnet} />,
-          confidenceInterval: (
-            <LiveConfidence feedKey={key} cluster={Cluster.Pythnet} />
-          ),
-          priceFeedName: <PriceFeedTag compact symbol={symbol} />,
-          assetClass: <AssetClassTag symbol={symbol} />,
-          priceFeedId: <FeedKey size="xs" variant="ghost" feedKey={key} />,
-        },
-      })),
+      paginatedItems.map(
+        ({ displaySymbol, symbol, exponent, numQuoters, key }) => ({
+          id: symbol,
+          href: `/price-feeds/${encodeURIComponent(symbol)}`,
+          textValue: displaySymbol,
+          data: {
+            exponent: (
+              <LiveValue
+                field="exponent"
+                feedKey={key}
+                defaultValue={exponent}
+                cluster={Cluster.Pythnet}
+              />
+            ),
+            numPublishers: (
+              <LiveValue
+                field="numQuoters"
+                feedKey={key}
+                defaultValue={numQuoters}
+                cluster={Cluster.Pythnet}
+              />
+            ),
+            price: <LivePrice feedKey={key} cluster={Cluster.Pythnet} />,
+            confidenceInterval: (
+              <LiveConfidence feedKey={key} cluster={Cluster.Pythnet} />
+            ),
+            priceFeedName: <PriceFeedTag compact symbol={symbol} />,
+            assetClass: <AssetClassTag symbol={symbol} />,
+            priceFeedId: (
+              <FeedKey
+                size="xs"
+                variant="ghost"
+                feedKey={key}
+                className={styles.feedKey ?? ""}
+              />
+            ),
+          },
+        }),
+      ),
     [paginatedItems],
   );
 
@@ -211,7 +215,7 @@ type PriceFeedsCardContents = Pick<Props, "id"> &
         onPageSizeChange: (newPageSize: number) => void;
         onPageChange: (newPage: number) => void;
         mkPageLink: (page: number) => string;
-        rows: RowConfig<
+        rows: (RowConfig<
           | "priceFeedName"
           | "assetClass"
           | "priceFeedId"
@@ -219,7 +223,7 @@ type PriceFeedsCardContents = Pick<Props, "id"> &
           | "confidenceInterval"
           | "exponent"
           | "numPublishers"
-        >[];
+        > & { textValue: string })[];
       }
   );
 
@@ -227,6 +231,7 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
   <Card
     id={id}
     icon={<ChartLine />}
+    className={styles.priceFeedsCard}
     title={
       <>
         <span>Price Feeds</span>
@@ -237,8 +242,21 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
         )}
       </>
     }
+    toolbarClassName={styles.toolbar}
     toolbar={
       <>
+        <SearchInput
+          size="sm"
+          width={50}
+          placeholder="Feed symbol"
+          className={styles.searchInput ?? ""}
+          {...(props.isLoading
+            ? { isPending: true, isDisabled: true }
+            : {
+                value: props.search,
+                onChange: props.onSearchChange,
+              })}
+        />
         <Select<string>
           label="Asset Class"
           size="sm"
@@ -260,17 +278,6 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
                 onSelectionChange: props.onAssetClassChange,
               })}
         />
-        <SearchInput
-          size="sm"
-          width={50}
-          placeholder="Feed symbol"
-          {...(props.isLoading
-            ? { isPending: true, isDisabled: true }
-            : {
-                value: props.search,
-                onChange: props.onSearchChange,
-              })}
-        />
       </>
     }
     {...(!props.isLoading && {
@@ -287,11 +294,38 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
       ),
     })}
   >
+    <EntityList
+      label="Price Feeds"
+      className={styles.entityList ?? ""}
+      headerLoadingSkeleton={<PriceFeedTag compact isLoading />}
+      fields={[
+        { id: "assetClass", name: "Asset Class" },
+        { id: "priceFeedId", name: "Price Feed ID" },
+        { id: "confidenceInterval", name: "Confidence Interval" },
+        { id: "exponent", name: "Exponent" },
+        { id: "numPublishers", name: "# Publishers" },
+      ]}
+      isLoading={props.isLoading}
+      rows={
+        props.isLoading
+          ? []
+          : props.rows.map((row) => ({
+              ...row,
+              header: (
+                <>
+                  {row.data.priceFeedName}
+                  {row.data.price}
+                </>
+              ),
+            }))
+      }
+    />
     <Table
       rounded
       fill
       label="Price Feeds"
       stickyHeader={rootStyles.headerHeight}
+      className={styles.table ?? ""}
       columns={[
         {
           id: "priceFeedName",

+ 31 - 21
apps/insights/src/components/Publisher/layout.module.scss

@@ -2,35 +2,27 @@
 
 .publisherLayout {
   .header {
-    @include theme.max-width;
-
-    margin-bottom: theme.spacing(6);
+    margin-bottom: theme.spacing(4);
     display: flex;
     flex-flow: column nowrap;
-    gap: theme.spacing(8);
+    gap: theme.spacing(4);
 
-    .breadcrumbRow,
-    .stats {
-      display: flex;
-      flex-flow: row nowrap;
-      align-items: center;
+    @include theme.max-width;
+
+    @include theme.breakpoint("sm") {
+      margin-bottom: theme.spacing(6);
+      gap: theme.spacing(6);
     }
 
     .breadcrumbRow {
+      display: flex;
+      flex-flow: row nowrap;
       align-items: center;
       justify-content: space-between;
       margin-bottom: -#{theme.spacing(2)};
     }
 
     .stats {
-      align-items: stretch;
-      gap: theme.spacing(6);
-
-      & > * {
-        flex: 1 1 0px;
-        width: 0;
-      }
-
       .activeDate {
         color: theme.color("muted");
       }
@@ -55,9 +47,9 @@
   }
 
   .body {
-    @include theme.max-width;
-
     padding-top: theme.spacing(6);
+
+    @include theme.max-width;
   }
 }
 
@@ -68,9 +60,9 @@
     grid-template-rows: repeat(4, max-content);
     gap: theme.spacing(4);
 
-    .oisMeter {
+    .oisMeter,
+    .smallOisMeter {
       grid-column: span 2 / span 2;
-      margin-bottom: -#{theme.spacing(12)};
 
       .oisMeterIcon {
         font-size: theme.spacing(6);
@@ -83,6 +75,24 @@
         @include theme.text("xl", "medium");
       }
     }
+
+    .smallOisMeter {
+      margin-top: -#{theme.spacing(12)};
+      margin-bottom: -#{theme.spacing(8)};
+
+      @include theme.breakpoint("md") {
+        display: none;
+      }
+    }
+
+    .oisMeter {
+      margin-bottom: -#{theme.spacing(12)};
+      display: none;
+
+      @include theme.breakpoint("md") {
+        display: grid;
+      }
+    }
   }
 
   .oisDrawerFooter {

+ 14 - 2
apps/insights/src/components/Publisher/layout.tsx

@@ -25,6 +25,7 @@ import {
 import { getPublisherCaps } from "../../services/hermes";
 import { Cluster, ClusterToName, parseCluster } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
+import { Cards } from "../Cards";
 import { ChangePercent } from "../ChangePercent";
 import { ChangeValue } from "../ChangeValue";
 import { ChartCard } from "../ChartCard";
@@ -116,7 +117,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               icon: <PublisherIcon knownPublisher={knownPublisher} />,
             })}
           />
-          <section className={styles.stats}>
+          <Cards className={styles.stats ?? ""}>
             <ChartCard
               variant="primary"
               header="Publisher Ranking"
@@ -316,6 +317,17 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                     </>
                   }
                 >
+                  <SemicircleMeter
+                    width={260}
+                    height={310}
+                    value={Number(oisStats.poolUtilization)}
+                    maxValue={oisStats.maxPoolSize}
+                    className={styles.smallOisMeter ?? ""}
+                    aria-label="OIS Pool Utilization"
+                  >
+                    <TokenIcon className={styles.oisMeterIcon} />
+                    <div className={styles.oisMeterLabel}>OIS Pool</div>
+                  </SemicircleMeter>
                   <SemicircleMeter
                     width={420}
                     height={420}
@@ -367,7 +379,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                 </Drawer>
               </DrawerTrigger>
             )}
-          </section>
+          </Cards>
         </section>
         <TabRoot>
           <Tabs

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

@@ -2,11 +2,35 @@
 
 .performance {
   display: grid;
-  grid-template-columns: 1fr 1fr;
+  grid-template-columns: 1fr;
   gap: theme.spacing(12) theme.spacing(6);
   align-items: flex-start;
 
-  > *:first-child {
-    grid-column: span 2 / span 2;
+  @include theme.breakpoint("lg") {
+    grid-template-columns: 1fr 1fr;
+
+    .publishersRankingCard {
+      grid-column: span 2 / span 2;
+    }
+  }
+
+  .publishersRankingCard {
+    .publishersRankingList {
+      @include theme.breakpoint("sm") {
+        display: none;
+      }
+
+      .ranking {
+        width: theme.spacing(20);
+      }
+    }
+
+    .publishersRankingTable {
+      display: none;
+
+      @include theme.breakpoint("sm") {
+        display: unset;
+      }
+    }
   }
 }

+ 139 - 92
apps/insights/src/components/Publisher/performance.tsx

@@ -8,6 +8,7 @@ import { Link } from "@pythnetwork/component-library/Link";
 import { Table } from "@pythnetwork/component-library/Table";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
+import type { ReactNode } from "react";
 
 import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./performance.module.scss";
@@ -15,12 +16,13 @@ import { TopFeedsTable } from "./top-feeds-table";
 import { getPublishers } from "../../services/clickhouse";
 import { ClusterToName, parseCluster } from "../../services/pyth";
 import { Status } from "../../status";
+import { EntityList } from "../EntityList";
 import {
   ExplainActive,
   ExplainInactive,
   ExplainAverage,
 } from "../Explanations";
-import { NoResults } from "../NoResults";
+import { type Variant as NoResultsVariant, NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherTag } from "../PublisherTag";
@@ -52,15 +54,95 @@ export const Performance = async ({ params }: Props) => {
     (publisher) => publisher.key === key,
     2,
   );
+  const rows = slicedPublishers?.map((publisher) => {
+    const knownPublisher = lookup(publisher.key);
+    return {
+      id: publisher.key,
+      nameAsString: knownPublisher?.name ?? publisher.key,
+      data: {
+        ranking: (
+          <Ranking isCurrent={publisher.key === key} className={styles.ranking}>
+            {publisher.rank}
+          </Ranking>
+        ),
+        activeFeeds: (
+          <Link
+            href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Active`}
+            invert
+          >
+            {publisher.activeFeeds}
+          </Link>
+        ),
+        inactiveFeeds: (
+          <Link
+            href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Inactive`}
+            invert
+          >
+            {publisher.inactiveFeeds}
+          </Link>
+        ),
+        averageScore: (
+          <Score width={PUBLISHER_SCORE_WIDTH} score={publisher.averageScore} />
+        ),
+        name: (
+          <PublisherTag
+            cluster={parsedCluster}
+            publisherKey={publisher.key}
+            {...(knownPublisher && {
+              name: knownPublisher.name,
+              icon: <PublisherIcon knownPublisher={knownPublisher} />,
+            })}
+          />
+        ),
+      },
+      ...(publisher.key !== key && {
+        href: `/publishers/${ClusterToName[parsedCluster]}/${publisher.key}`,
+      }),
+    };
+  });
+
+  const highPerformingFeeds = getFeedRows(
+    priceFeeds
+      .filter((feed) => hasRanking(feed))
+      .filter(({ ranking }) => ranking.final_score > 0.9)
+      .sort((a, b) => b.ranking.final_score - a.ranking.final_score),
+  );
+
+  const lowPerformingFeeds = getFeedRows(
+    priceFeeds
+      .filter((feed) => hasRanking(feed))
+      .filter(({ ranking }) => ranking.final_score < 0.7)
+      .sort((a, b) => a.ranking.final_score - b.ranking.final_score),
+  );
 
-  return slicedPublishers === undefined ? (
+  return rows === undefined ? (
     notFound()
   ) : (
     <div className={styles.performance}>
-      <Card icon={<Broadcast />} title="Publishers Ranking">
+      <Card
+        icon={<Broadcast />}
+        title="Publishers Ranking"
+        className={styles.publishersRankingCard ?? ""}
+      >
+        <EntityList
+          label="Publishers Ranking"
+          className={styles.publishersRankingList ?? ""}
+          fields={[
+            { id: "ranking", name: "Ranking" },
+            { id: "averageScore", name: "Average Score" },
+            { id: "activeFeeds", name: "Active Feeds" },
+            { id: "inactiveFeeds", name: "Inactive Feeds" },
+          ]}
+          rows={rows.map((row) => ({
+            ...row,
+            textValue: row.nameAsString,
+            header: row.data.name,
+          }))}
+        />
         <Table
           rounded
           fill
+          className={styles.publishersRankingTable ?? ""}
           label="Publishers Ranking"
           columns={[
             {
@@ -108,96 +190,25 @@ export const Performance = async ({ params }: Props) => {
               width: PUBLISHER_SCORE_WIDTH,
             },
           ]}
-          rows={slicedPublishers.map((publisher) => {
-            const knownPublisher = lookup(publisher.key);
-            return {
-              id: publisher.key,
-              data: {
-                ranking: (
-                  <Ranking isCurrent={publisher.key === key}>
-                    {publisher.rank}
-                  </Ranking>
-                ),
-                activeFeeds: (
-                  <Link
-                    href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Active`}
-                    invert
-                  >
-                    {publisher.activeFeeds}
-                  </Link>
-                ),
-                inactiveFeeds: (
-                  <Link
-                    href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Inactive`}
-                    invert
-                  >
-                    {publisher.inactiveFeeds}
-                  </Link>
-                ),
-                averageScore: (
-                  <Score
-                    width={PUBLISHER_SCORE_WIDTH}
-                    score={publisher.averageScore}
-                  />
-                ),
-                name: (
-                  <PublisherTag
-                    cluster={parsedCluster}
-                    publisherKey={publisher.key}
-                    {...(knownPublisher && {
-                      name: knownPublisher.name,
-                      icon: <PublisherIcon knownPublisher={knownPublisher} />,
-                    })}
-                  />
-                ),
-              },
-              ...(publisher.key !== key && {
-                href: `/publishers/${ClusterToName[parsedCluster]}/${publisher.key}`,
-              }),
-            };
-          })}
-        />
-      </Card>
-      <Card icon={<Network />} title="High-Performing Feeds">
-        <TopFeedsTable
-          label="High-Performing Feeds"
-          publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
-          emptyState={
-            <NoResults
-              icon={<SmileySad />}
-              header="Oh no!"
-              body="This publisher has no high performing feeds"
-              variant="error"
-            />
-          }
-          rows={getFeedRows(
-            priceFeeds
-              .filter((feed) => hasRanking(feed))
-              .filter(({ ranking }) => ranking.final_score > 0.9)
-              .sort((a, b) => b.ranking.final_score - a.ranking.final_score),
-          )}
-        />
-      </Card>
-      <Card icon={<Network />} title="Low-Performing Feeds">
-        <TopFeedsTable
-          label="Low-Performing Feeds"
-          publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
-          emptyState={
-            <NoResults
-              icon={<Confetti />}
-              header="Looking good!"
-              body="This publisher has no low performing feeds"
-              variant="success"
-            />
-          }
-          rows={getFeedRows(
-            priceFeeds
-              .filter((feed) => hasRanking(feed))
-              .filter(({ ranking }) => ranking.final_score < 0.7)
-              .sort((a, b) => a.ranking.final_score - b.ranking.final_score),
-          )}
+          rows={rows}
         />
       </Card>
+      <TopFeedsCard
+        title="High-Performing"
+        emptyIcon={<SmileySad />}
+        emptyHeader="Oh no!"
+        emptyBody="This publisher has no high performing feeds"
+        emptyVariant="error"
+        feeds={highPerformingFeeds}
+      />
+      <TopFeedsCard
+        title="Low-Performing"
+        emptyIcon={<Confetti />}
+        emptyHeader="Looking good!"
+        emptyBody="This publisher has no low performing feeds"
+        emptyVariant="success"
+        feeds={lowPerformingFeeds}
+      />
     </div>
   );
 };
@@ -216,7 +227,8 @@ const getFeedRows = (
     .filter((feed) => feed.status === Status.Active)
     .slice(0, 20)
     .map(({ feed, ranking }) => ({
-      id: ranking.symbol,
+      id: feed.symbol,
+      textValue: feed.symbol,
       data: {
         asset: <PriceFeedTag compact symbol={feed.symbol} />,
         assetClass: (
@@ -251,3 +263,38 @@ const sliceAround = <T,>(
 const hasRanking = <T,>(feed: {
   ranking: T | undefined;
 }): feed is { ranking: T } => feed.ranking !== undefined;
+
+type TopFeedsCardProps = {
+  title: string;
+  emptyIcon: ReactNode;
+  emptyHeader: string;
+  emptyBody: string;
+  emptyVariant: NoResultsVariant;
+  feeds: ReturnType<typeof getFeedRows>;
+};
+
+const TopFeedsCard = ({
+  title,
+  emptyIcon,
+  emptyHeader,
+  emptyBody,
+  emptyVariant,
+  feeds,
+}: TopFeedsCardProps) => (
+  <Card icon={<Network />} title={`${title} Feeds`}>
+    {feeds.length === 0 ? (
+      <NoResults
+        icon={emptyIcon}
+        header={emptyHeader}
+        body={emptyBody}
+        variant={emptyVariant}
+      />
+    ) : (
+      <TopFeedsTable
+        label={`${title} Feeds`}
+        publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
+        rows={feeds}
+      />
+    )}
+  </Card>
+);

+ 15 - 0
apps/insights/src/components/Publisher/top-feeds-table.module.scss

@@ -0,0 +1,15 @@
+@use "@pythnetwork/component-library/theme";
+
+.list {
+  @include theme.breakpoint("lg") {
+    display: none;
+  }
+}
+
+.table {
+  display: none;
+
+  @include theme.breakpoint("lg") {
+    display: unset;
+  }
+}

+ 47 - 35
apps/insights/src/components/Publisher/top-feeds-table.tsx

@@ -1,22 +1,19 @@
 "use client";
 
 import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
-import { type ReactNode, useMemo } from "react";
+import { useMemo } from "react";
 
 import { useSelectPriceFeed } from "./price-feed-drawer-provider";
+import styles from "./top-feeds-table.module.scss";
+import { EntityList } from "../EntityList";
 
 type Props = {
   publisherScoreWidth: number;
-  rows: RowConfig<"score" | "asset" | "assetClass">[];
-  emptyState: ReactNode;
+  rows: (RowConfig<"score" | "asset" | "assetClass"> & { textValue: string })[];
   label: string;
 };
 
-export const TopFeedsTable = ({
-  publisherScoreWidth,
-  rows,
-  ...props
-}: Props) => {
+export const TopFeedsTable = ({ publisherScoreWidth, rows, label }: Props) => {
   const selectPriceFeed = useSelectPriceFeed();
 
   const rowsWithAction = useMemo(
@@ -33,32 +30,47 @@ export const TopFeedsTable = ({
   );
 
   return (
-    <Table
-      rounded
-      fill
-      columns={[
-        {
-          id: "score",
-          name: "SCORE",
-          alignment: "left",
-          width: publisherScoreWidth,
-        },
-        {
-          id: "asset",
-          name: "ASSET",
-          isRowHeader: true,
-          alignment: "left",
-        },
-        {
-          id: "assetClass",
-          name: "ASSET CLASS",
-          alignment: "right",
-          width: 40,
-        },
-      ]}
-      hideHeadersInEmptyState
-      rows={rowsWithAction}
-      {...props}
-    />
+    <>
+      <EntityList
+        label={label}
+        className={styles.list ?? ""}
+        fields={[
+          { id: "score", name: "Score" },
+          { id: "assetClass", name: "Asset Class" },
+        ]}
+        rows={rowsWithAction.map((row) => ({
+          ...row,
+          textValue: row.textValue,
+          header: row.data.asset,
+        }))}
+      />
+      <Table
+        label={label}
+        rounded
+        fill
+        className={styles.table ?? ""}
+        columns={[
+          {
+            id: "score",
+            name: "SCORE",
+            alignment: "left",
+            width: publisherScoreWidth,
+          },
+          {
+            id: "asset",
+            name: "ASSET",
+            isRowHeader: true,
+            alignment: "left",
+          },
+          {
+            id: "assetClass",
+            name: "ASSET CLASS",
+            alignment: "right",
+            width: 40,
+          },
+        ]}
+        rows={rowsWithAction}
+      />
+    </>
   );
 };

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

@@ -63,7 +63,7 @@
   }
 
   .testBadge {
-    margin-left: theme.spacing(4);
+    margin-left: theme.spacing(2);
   }
 
   &[data-loading] {

+ 77 - 52
apps/insights/src/components/Publishers/index.module.scss

@@ -1,6 +1,8 @@
 @use "@pythnetwork/component-library/theme";
 @use "../Root/index.module.scss" as root;
 
+$gap: theme.spacing(4);
+
 .publishers {
   @include theme.max-width;
 
@@ -11,21 +13,24 @@
     justify-content: space-between;
 
     .header {
-      @include theme.h3;
-
       color: theme.color("heading");
-      font-weight: theme.font-weight("semibold");
+
+      @include theme.h3;
     }
 
     .rankingsLastUpdated {
       @include theme.text("sm", "normal");
 
       color: theme.color("muted");
-      display: flex;
       flex-flow: row nowrap;
       gap: theme.spacing(1);
       align-items: center;
       line-height: normal;
+      display: none;
+
+      @include theme.breakpoint("sm") {
+        display: flex;
+      }
 
       .clockIcon {
         font-size: theme.spacing(5);
@@ -34,62 +39,82 @@
   }
 
   .body {
-    display: flex;
-    flex-flow: row nowrap;
-    gap: theme.spacing(12);
-    align-items: flex-start;
-    margin-top: theme.spacing(6);
-
-    .stats {
-      display: grid;
-      grid-template-columns: repeat(2, minmax(0, 1fr));
-      gap: theme.spacing(4);
-      align-items: center;
-      width: 30%;
-      position: sticky;
-      top: root.$header-height;
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: $gap;
+    margin-top: theme.spacing(4);
+
+    @include theme.breakpoint("2xl") {
+      grid-template-columns: 15% 15% 1fr;
+      grid-template-rows: max-content 1fr;
+      margin-top: theme.spacing(6);
+    }
+
+    .statCard {
+      @include theme.breakpoint("2xl") {
+        position: sticky;
+        top: root.$header-height;
+      }
+    }
 
-      .averageMedianScoreExplainButton {
-        margin-top: -#{theme.button-padding("xs", false)};
-        margin-right: -#{theme.button-padding("xs", false)};
+    .publishersCard {
+      grid-column: span 2 / span 2;
+
+      @include theme.breakpoint("2xl") {
+        grid-column: 3;
+        grid-row: span 2 / span 2;
+        margin-left: theme.spacing(6);
+      }
+    }
+
+    .oisCard {
+      grid-column: span 2 / span 2;
+
+      @include theme.breakpoint("2xl") {
+        grid-row: 2;
+        align-self: start;
+        position: sticky;
+
+        // TODO the following should be made into variables so we don't have
+        // to copy the values around...
+        $card-content: theme.spacing(15);
+        $card-pt: theme.spacing(3);
+        $card-pb: theme.spacing(2);
+        $card-wrapper-p: (2 * theme.spacing(1));
+        $card-height: $card-content + $card-pt + $card-pb + $card-wrapper-p;
+
+        top: calc(root.$header-height + $gap + $card-height);
       }
 
-      .oisCard {
-        grid-column: span 2 / span 2;
-
-        .oisPool {
-          .title {
-            font-size: theme.font-size("sm");
-            font-weight: theme.font-weight("normal");
-            color: theme.color("heading");
-            margin: 0;
-          }
-
-          .poolUsed {
-            margin: 0;
-            color: theme.color("heading");
-
-            @include theme.h3;
-          }
-
-          .poolTotal {
-            margin: 0;
-            color: theme.color("muted");
-            font-size: theme.font-size("sm");
-            font-weight: theme.font-weight("normal");
-          }
+      .oisPool {
+        .title {
+          font-size: theme.font-size("sm");
+          font-weight: theme.font-weight("normal");
+          color: theme.color("heading");
+          margin: 0;
+        }
+
+        .poolUsed {
+          line-height: 125%;
+          letter-spacing: letter-spacing("tighter");
+          color: theme.color("heading");
+
+          @include theme.text("xl", "medium");
         }
 
-        .oisStats {
-          display: grid;
-          grid-template-columns: repeat(2, minmax(0, 1fr));
-          gap: theme.spacing(1);
+        .poolTotal {
+          margin: 0;
+          color: theme.color("muted");
+          font-size: theme.font-size("sm");
+          font-weight: theme.font-weight("normal");
         }
       }
-    }
 
-    .publishersCard {
-      width: 70%;
+      .oisStats {
+        display: grid;
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+        gap: theme.spacing(1);
+      }
     }
   }
 }

+ 74 - 77
apps/insights/src/components/Publishers/index.tsx

@@ -55,83 +55,23 @@ export const Publishers = async () => {
         )}
       </div>
       <div className={styles.body}>
-        <section className={styles.stats}>
-          <StatCard
-            variant="primary"
-            header="Active Publishers"
-            stat={pythnetPublishers.length}
-          />
-          <StatCard
-            header="Average Feed Score"
-            corner={<ExplainAverage scoreTime={scoreTime} />}
-            stat={(
-              pythnetPublishers.reduce(
-                (sum, publisher) => sum + publisher.averageScore,
-                0,
-              ) / pythnetPublishers.length
-            ).toFixed(2)}
-          />
-          <Card
-            title="Oracle Integrity Staking (OIS)"
-            className={styles.oisCard}
-            toolbar={
-              <Button
-                href="https://staking.pyth.network"
-                target="_blank"
-                size="sm"
-                variant="outline"
-                afterIcon={ArrowSquareOut}
-              >
-                Staking App
-              </Button>
-            }
-          >
-            <SemicircleMeter
-              width={340}
-              height={340}
-              value={Number(oisStats.totalStaked)}
-              maxValue={oisStats.maxPoolSize ?? 0}
-              className={styles.oisPool ?? ""}
-            >
-              <Label className={styles.title}>PYTH Staking Pool</Label>
-              <p className={styles.poolUsed}>
-                <FormattedTokens
-                  mode="wholePart"
-                  tokens={oisStats.totalStaked}
-                />
-              </p>
-              <p className={styles.poolTotal}>
-                /{" "}
-                <FormattedTokens
-                  mode="wholePart"
-                  tokens={BigInt(oisStats.maxPoolSize ?? 0)}
-                />
-              </p>
-            </SemicircleMeter>
-            <div className={styles.oisStats}>
-              <StatCard
-                header="Total Staked"
-                variant="tertiary"
-                stat={
-                  <>
-                    <TokenIcon />
-                    <FormattedTokens tokens={oisStats.totalStaked} />
-                  </>
-                }
-              />
-              <StatCard
-                header="Total Rewards Distributed"
-                variant="tertiary"
-                stat={
-                  <>
-                    <TokenIcon />
-                    <FormattedTokens tokens={oisStats.rewardsDistributed} />
-                  </>
-                }
-              />
-            </div>
-          </Card>
-        </section>
+        <StatCard
+          variant="primary"
+          header="Active Publishers"
+          stat={pythnetPublishers.length}
+          className={styles.statCard ?? ""}
+        />
+        <StatCard
+          header="Average Feed Score"
+          corner={<ExplainAverage scoreTime={scoreTime} />}
+          className={styles.statCard ?? ""}
+          stat={(
+            pythnetPublishers.reduce(
+              (sum, publisher) => sum + publisher.averageScore,
+              0,
+            ) / pythnetPublishers.length
+          ).toFixed(2)}
+        />
         <PublishersCard
           className={styles.publishersCard}
           explainAverage={<ExplainAverage scoreTime={scoreTime} />}
@@ -142,6 +82,63 @@ export const Publishers = async () => {
             (publisher) => toTableRow(publisher),
           )}
         />
+        <Card
+          title="Oracle Integrity Staking (OIS)"
+          className={styles.oisCard}
+          toolbar={
+            <Button
+              href="https://staking.pyth.network"
+              target="_blank"
+              size="sm"
+              variant="outline"
+              afterIcon={ArrowSquareOut}
+            >
+              Staking App
+            </Button>
+          }
+        >
+          <SemicircleMeter
+            width={340}
+            height={340}
+            value={Number(oisStats.totalStaked)}
+            maxValue={oisStats.maxPoolSize ?? 0}
+            className={styles.oisPool ?? ""}
+          >
+            <Label className={styles.title}>PYTH Staking Pool</Label>
+            <p className={styles.poolUsed}>
+              <FormattedTokens mode="wholePart" tokens={oisStats.totalStaked} />
+            </p>
+            <p className={styles.poolTotal}>
+              /{" "}
+              <FormattedTokens
+                mode="wholePart"
+                tokens={BigInt(oisStats.maxPoolSize ?? 0)}
+              />
+            </p>
+          </SemicircleMeter>
+          <div className={styles.oisStats}>
+            <StatCard
+              header="Total Staked"
+              variant="tertiary"
+              stat={
+                <>
+                  <TokenIcon />
+                  <FormattedTokens tokens={oisStats.totalStaked} />
+                </>
+              }
+            />
+            <StatCard
+              header="Total Rewards Distributed"
+              variant="tertiary"
+              stat={
+                <>
+                  <TokenIcon />
+                  <FormattedTokens tokens={oisStats.rewardsDistributed} />
+                </>
+              }
+            />
+          </div>
+        </Card>
       </div>
     </div>
   );

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

@@ -0,0 +1,41 @@
+@use "@pythnetwork/component-library/theme";
+
+.publishersCard {
+  .toolbar {
+    .searchInput {
+      flex-grow: 1;
+    }
+  }
+
+  .table {
+    display: none;
+
+    @include theme.breakpoint("lg") {
+      display: unset;
+    }
+  }
+
+  .entityList {
+    @include theme.breakpoint("lg") {
+      display: none;
+    }
+
+    @include theme.breakpoint("sm") {
+      dl > *:nth-child(1) {
+        order: 2;
+      }
+
+      dl > *:nth-child(2) {
+        order: 1;
+      }
+
+      dl > *:nth-child(3) {
+        order: 3;
+      }
+    }
+
+    .rankingWraper {
+      width: theme.spacing(15);
+    }
+  }
+}

+ 45 - 14
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -14,12 +14,15 @@ import {
   type SortDescriptor,
   Table,
 } from "@pythnetwork/component-library/Table";
+import clsx from "clsx";
 import { useQueryState, parseAsStringEnum } from "nuqs";
 import { type ReactNode, Suspense, useMemo, useCallback } from "react";
 import { useFilter, useCollator } from "react-aria";
 
+import styles from "./publishers-card.module.scss";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { CLUSTER_NAMES } from "../../services/pyth";
+import { EntityList } from "../EntityList";
 import { ExplainActive, ExplainInactive } from "../Explanations";
 import { NoResults } from "../NoResults";
 import { PublisherTag } from "../PublisherTag";
@@ -133,6 +136,7 @@ const ResolvedPublishersCard = ({
         }) => ({
           id,
           href: `/publishers/${cluster}/${id}`,
+          textValue: publisher.name ?? id,
           data: {
             ranking: <Ranking>{ranking}</Ranking>,
             name: (
@@ -218,9 +222,9 @@ type PublishersCardContentsProps = Pick<Props, "className" | "explainAverage"> &
         mkPageLink: (page: number) => string;
         cluster: (typeof CLUSTER_NAMES)[number];
         onChangeCluster: (value: (typeof CLUSTER_NAMES)[number]) => void;
-        rows: RowConfig<
+        rows: (RowConfig<
           "ranking" | "name" | "activeFeeds" | "inactiveFeeds" | "averageScore"
-        >[];
+        > & { textValue: string })[];
       }
   );
 
@@ -230,7 +234,7 @@ const PublishersCardContents = ({
   ...props
 }: PublishersCardContentsProps) => (
   <Card
-    className={className}
+    className={clsx(styles.publishersCard, className)}
     icon={<Broadcast />}
     title={
       <>
@@ -242,8 +246,21 @@ const PublishersCardContents = ({
         )}
       </>
     }
+    toolbarClassName={styles.toolbar}
     toolbar={
       <>
+        <SearchInput
+          size="sm"
+          width={60}
+          placeholder="Publisher key or name"
+          className={styles.searchInput ?? ""}
+          {...(props.isLoading
+            ? { isPending: true, isDisabled: true }
+            : {
+                value: props.search,
+                onChange: props.onSearchChange,
+              })}
+        />
         <Select
           label="Cluster"
           size="sm"
@@ -259,17 +276,6 @@ const PublishersCardContents = ({
                 onSelectionChange: props.onChangeCluster,
               })}
         />
-        <SearchInput
-          size="sm"
-          width={60}
-          placeholder="Publisher key or name"
-          {...(props.isLoading
-            ? { isPending: true, isDisabled: true }
-            : {
-                value: props.search,
-                onChange: props.onSearchChange,
-              })}
-        />
       </>
     }
     {...(!props.isLoading && {
@@ -286,11 +292,36 @@ const PublishersCardContents = ({
       ),
     })}
   >
+    <EntityList
+      label="Publishers"
+      className={styles.entityList ?? ""}
+      headerLoadingSkeleton={<PublisherTag isLoading />}
+      fields={[
+        { id: "averageScore", name: "Average Score" },
+        { id: "activeFeeds", name: "Active Feeds" },
+        { id: "inactiveFeeds", name: "Inactive Feeds" },
+      ]}
+      isLoading={props.isLoading}
+      rows={
+        props.isLoading
+          ? []
+          : props.rows.map((row) => ({
+              ...row,
+              header: (
+                <>
+                  {row.data.name}
+                  <div className={styles.rankingWraper}>{row.data.ranking}</div>
+                </>
+              ),
+            }))
+      }
+    />
     <Table
       rounded
       fill
       label="Publishers"
       stickyHeader={rootStyles.headerHeight}
+      className={styles.table ?? ""}
       columns={[
         {
           id: "ranking",

+ 36 - 34
apps/insights/src/components/Root/footer.module.scss

@@ -1,42 +1,31 @@
 @use "@pythnetwork/component-library/theme";
 
 .footer {
-  // SM
   background: theme.color("background", "primary");
-
-  // XL
   padding: theme.spacing(8) 0;
 
-  // bg-beige-100 sm:border-t sm:border-stone-300
-
   .topContent {
     display: flex;
     gap: theme.spacing(6);
-
-    // SM
-    flex-flow: row nowrap;
-    align-items: center;
+    flex-flow: column nowrap;
+    align-items: stretch;
     justify-content: space-between;
+    margin-bottom: theme.spacing(6);
 
     @include theme.max-width;
 
-    // XL
-    margin-bottom: theme.spacing(12);
-
-    // py-6
-
-    // flex-col
+    @include theme.breakpoint("sm") {
+      flex-flow: row nowrap;
+      align-items: center;
+      margin-bottom: theme.spacing(12);
+    }
 
-    .left {
+    .main {
       display: flex;
       align-items: stretch;
       justify-content: space-between;
-
-      // SM
       gap: theme.spacing(6);
 
-      // gap-8
-
       .logoLink {
         height: theme.spacing(5);
         box-sizing: content-box;
@@ -54,47 +43,60 @@
       }
 
       .divider {
+        display: none;
         background-color: theme.color("border");
         width: 1px;
 
-        // hidden sm:block
+        @include theme.breakpoint("sm") {
+          display: unset;
+        }
       }
 
       .help {
         display: flex;
         flex-flow: row nowrap;
         align-items: center;
-        gap: theme.spacing(6);
+        gap: theme.spacing(3);
         font-size: theme.font-size("sm");
+
+        @include theme.breakpoint("sm") {
+          gap: theme.spacing(6);
+        }
       }
     }
 
-    .right {
-      margin: 0 -#{theme.button-padding("sm", false)};
+    .socialLinks {
       display: flex;
       flex-flow: row nowrap;
       align-items: center;
-
-      // SM
-      justify-content: flex-end;
+      justify-content: space-between;
       gap: theme.spacing(2);
-
-      // justify-between
+      max-width: theme.spacing(80);
+      width: 100%;
+      align-self: center;
+
+      @include theme.breakpoint("sm") {
+        margin: 0 -#{theme.button-padding("sm", false)};
+        width: unset;
+        max-width: unset;
+        align-self: unset;
+        justify-content: flex-end;
+      }
     }
   }
 
   .bottomContent {
     display: flex;
     gap: theme.spacing(6);
-
-    // SM
-    flex-flow: row nowrap;
+    flex-flow: column nowrap;
     justify-content: space-between;
 
-    // "flex-col
-
     @include theme.max-width;
 
+    @include theme.breakpoint("sm") {
+      flex-flow: row nowrap;
+    }
+
     .copyright {
       font-size: theme.font-size("xs");
       color: theme.color("muted");

+ 6 - 4
apps/insights/src/components/Root/footer.tsx

@@ -2,6 +2,7 @@ import {
   type Props as ButtonProps,
   Button,
 } from "@pythnetwork/component-library/Button";
+import { DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Link } from "@pythnetwork/component-library/Link";
 import type { ComponentProps, ElementType } from "react";
 
@@ -13,22 +14,23 @@ import Wordmark from "./wordmark.svg";
 export const Footer = () => (
   <footer className={styles.footer}>
     <div className={styles.topContent}>
-      <div className={styles.left}>
+      <div className={styles.main}>
         <Link href="https://www.pyth.network" className={styles.logoLink ?? ""}>
           <Wordmark className={styles.logo} />
           <div className={styles.logoLabel}>Pyth Homepage</div>
         </Link>
         <div className={styles.divider} />
         <div className={styles.help}>
-          <SupportDrawer>
+          <DrawerTrigger>
             <Link>Help</Link>
-          </SupportDrawer>
+            <SupportDrawer />
+          </DrawerTrigger>
           <Link href="https://docs.pyth.network" target="_blank">
             Documentation
           </Link>
         </div>
       </div>
-      <div className={styles.right}>
+      <div className={styles.socialLinks}>
         {socialLinks.map(({ name, ...props }) => (
           <SocialLink {...props} key={name}>
             {name}

+ 57 - 7
apps/insights/src/components/Root/header.module.scss

@@ -15,10 +15,14 @@
     @include theme.max-width;
 
     .leftMenu {
+      @include theme.row;
+
       flex: none;
-      gap: theme.spacing(6);
+      gap: theme.spacing(3);
 
-      @include theme.row;
+      @include theme.breakpoint("sm") {
+        gap: theme.spacing(6);
+      }
 
       .logoLink {
         padding: theme.spacing(3);
@@ -26,10 +30,15 @@
         color: theme.color("foreground");
 
         .logoWrapper {
-          width: theme.spacing(9);
-          height: theme.spacing(9);
+          width: theme.spacing(8);
+          height: theme.spacing(8);
           position: relative;
 
+          @include theme.breakpoint("sm") {
+            width: theme.spacing(9);
+            height: theme.spacing(9);
+          }
+
           .logo {
             position: absolute;
             top: 0;
@@ -48,16 +57,57 @@
         font-weight: theme.font-weight("semibold");
         color: theme.color("heading");
       }
+
+      .mainNavTabs {
+        display: none;
+
+        @include theme.breakpoint("sm") {
+          display: flex;
+        }
+      }
     }
 
     .rightMenu {
-      flex: none;
-      gap: theme.spacing(2);
-
       @include theme.row;
 
+      flex: none;
+      gap: theme.spacing(3);
       margin-right: -#{theme.button-padding("sm", false)};
 
+      @include theme.breakpoint("lg") {
+        gap: theme.spacing(2);
+      }
+
+      .supportButton,
+      .themeSwitch,
+      .mainCta {
+        display: none;
+
+        @include theme.breakpoint("lg") {
+          display: unset;
+        }
+      }
+
+      .outlineSearchButton {
+        display: none;
+
+        @include theme.breakpoint("md") {
+          display: unset;
+        }
+      }
+
+      .ghostSearchButton {
+        @include theme.breakpoint("md") {
+          display: none;
+        }
+      }
+
+      .mobileMenu {
+        @include theme.breakpoint("lg") {
+          display: none;
+        }
+      }
+
       .themeSwitch {
         margin-left: theme.spacing(1);
       }

+ 34 - 7
apps/insights/src/components/Root/header.tsx

@@ -1,17 +1,23 @@
 import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
 import { Button } from "@pythnetwork/component-library/Button";
+import { DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Link } from "@pythnetwork/component-library/Link";
 import clsx from "clsx";
 import type { ComponentProps } from "react";
 
 import styles from "./header.module.scss";
 import Logo from "./logo.svg";
-import { SearchButton } from "./search-button";
+import { MobileMenu } from "./mobile-menu";
+import { SearchButton, SearchShortcutText } from "./search-button";
 import { SupportDrawer } from "./support-drawer";
 import { MainNavTabs } from "./tabs";
 import { ThemeSwitch } from "./theme-switch";
 
-export const Header = ({ className, ...props }: ComponentProps<"header">) => (
+type Props = ComponentProps<"header"> & {
+  tabs: ComponentProps<typeof MainNavTabs>["items"];
+};
+
+export const Header = ({ className, tabs, ...props }: Props) => (
   <header className={clsx(styles.header, className)} {...props}>
     <div className={styles.content}>
       <div className={styles.leftMenu}>
@@ -22,20 +28,41 @@ export const Header = ({ className, ...props }: ComponentProps<"header">) => (
           <div className={styles.logoLabel}>Pyth Homepage</div>
         </Link>
         <div className={styles.appName}>Insights</div>
-        <MainNavTabs />
+        <MainNavTabs className={styles.mainNavTabs ?? ""} items={tabs} />
       </div>
       <div className={styles.rightMenu}>
-        <SupportDrawer>
-          <Button beforeIcon={Lifebuoy} variant="ghost" size="sm" rounded>
+        <DrawerTrigger>
+          <Button
+            beforeIcon={Lifebuoy}
+            variant="ghost"
+            size="sm"
+            rounded
+            className={styles.supportButton ?? ""}
+          >
             Support
           </Button>
-        </SupportDrawer>
-        <SearchButton />
+          <SupportDrawer />
+        </DrawerTrigger>
+        <SearchButton
+          className={styles.outlineSearchButton ?? ""}
+          variant="outline"
+        >
+          <SearchShortcutText />
+        </SearchButton>
+        <SearchButton
+          className={styles.ghostSearchButton ?? ""}
+          hideText
+          variant="ghost"
+        >
+          Search
+        </SearchButton>
+        <MobileMenu className={styles.mobileMenu} />
         <Button
           href="https://docs.pyth.network"
           size="sm"
           rounded
           target="_blank"
+          className={styles.mainCta ?? ""}
         >
           Dev Docs
         </Button>

+ 21 - 3
apps/insights/src/components/Root/index.module.scss

@@ -1,6 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
-$header-height: theme.spacing(20);
+$header-height: var(--header-height);
 
 :export {
   // stylelint-disable-next-line property-no-unknown
@@ -9,16 +9,28 @@ $header-height: theme.spacing(20);
 
 .root {
   scroll-padding-top: $header-height;
-  overflow-x: hidden;
+
+  --header-height: #{theme.spacing(18)};
+
+  @include theme.breakpoint("md") {
+    --header-height: #{theme.spacing(20)};
+  }
 
   .tabRoot {
     display: grid;
     min-height: 100dvh;
     grid-template-rows: auto 1fr auto;
+    grid-template-columns: 100%;
 
     .main {
       isolation: isolate;
-      padding-top: theme.spacing(6);
+      padding-top: theme.spacing(4);
+      min-height: calc(100svh - $header-height);
+
+      @include theme.breakpoint("sm") {
+        min-height: unset;
+        padding-top: theme.spacing(6);
+      }
     }
 
     .header {
@@ -26,4 +38,10 @@ $header-height: theme.spacing(20);
       height: $header-height;
     }
   }
+
+  .mobileNavTabs {
+    @include theme.breakpoint("sm") {
+      display: none;
+    }
+  }
 }

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

@@ -5,8 +5,8 @@ import type { ReactNode } from "react";
 
 import { Footer } from "./footer";
 import { Header } from "./header";
-// import { MobileMenu } from "./mobile-menu";
 import styles from "./index.module.scss";
+import { MobileNavTabs } from "./mobile-nav-tabs";
 import { SearchDialogProvider } from "./search-dialog";
 import { TabRoot, TabPanel } from "./tabs";
 import {
@@ -14,7 +14,6 @@ import {
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
 } from "../../config/server";
-// import { toHex } from "../../hex";
 import { LivePriceDataProvider } from "../../hooks/use-live-price-data";
 import { PriceFeedsProvider as PriceFeedsProviderImpl } from "../../hooks/use-price-feeds";
 import { getPublishers } from "../../services/clickhouse";
@@ -22,6 +21,16 @@ import { Cluster, getFeeds } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
 
+export const TABS = [
+  { href: "/", id: "", children: "Overview" },
+  { href: "/publishers", id: "publishers", children: "Publishers" },
+  {
+    href: "/price-feeds",
+    id: "price-feeds",
+    children: "Price Feeds",
+  },
+];
+
 type Props = {
   children: ReactNode;
 };
@@ -42,11 +51,12 @@ export const Root = async ({ children }: Props) => {
     >
       <SearchDialogProvider publishers={publishers.flat()}>
         <TabRoot className={styles.tabRoot ?? ""}>
-          <Header className={styles.header} />
+          <Header className={styles.header} tabs={TABS} />
           <main className={styles.main}>
             <TabPanel>{children}</TabPanel>
           </main>
           <Footer />
+          <MobileNavTabs tabs={TABS} className={styles.mobileNavTabs} />
         </TabRoot>
       </SearchDialogProvider>
     </BaseRoot>

+ 30 - 0
apps/insights/src/components/Root/mobile-menu.module.scss

@@ -0,0 +1,30 @@
+@use "@pythnetwork/component-library/theme";
+
+.mobileMenu {
+  display: flex;
+  flex-flow: column nowrap;
+  align-items: stretch;
+  gap: theme.spacing(6);
+  justify-content: space-between;
+
+  .buttons {
+    display: flex;
+    flex-flow: column nowrap;
+    align-items: stretch;
+    gap: theme.spacing(6);
+  }
+
+  .theme {
+    display: flex;
+    flex-flow: row nowrap;
+    justify-content: flex-end;
+    align-items: center;
+    gap: theme.spacing(2);
+
+    .themeLabel {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("muted");
+    }
+  }
+}

+ 73 - 41
apps/insights/src/components/Root/mobile-menu.tsx

@@ -1,46 +1,78 @@
-import type { Icon } from "@phosphor-icons/react";
-import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
-import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
-import { List } from "@phosphor-icons/react/dist/ssr/List";
-import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
-import { PresentationChart } from "@phosphor-icons/react/dist/ssr/PresentationChart";
-import type { ComponentProps, ReactNode } from "react";
+"use client";
 
-import { NavLink } from "./nav-link";
+import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
+import { List } from "@phosphor-icons/react/dist/ssr/List";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
+import { useCallback, useState, useRef } from "react";
 
-export const MobileMenu = () => (
-  <nav className="contents lg:hidden">
-    <ul className="sticky bottom-0 isolate z-20 flex size-full flex-row items-stretch bg-white dark:bg-steel-950">
-      <MobileMenuItem title="Overview" icon={PresentationChart} href="/" />
-      <MobileMenuItem title="Publishers" icon={Broadcast} href="/publishers" />
-      <MobileMenuItem
-        title="Price Feeds"
-        icon={ChartLine}
-        href="/price-feeds"
-      />
-      <MobileMenuItem title="Search" icon={MagnifyingGlass} href="/" />
-      <MobileMenuItem title="More" icon={List} href="/" />
-    </ul>
-  </nav>
-);
+import styles from "./mobile-menu.module.scss";
+import { SupportDrawer } from "./support-drawer";
+import { ThemeSwitch } from "./theme-switch";
 
-type MobileMenuItemProps = ComponentProps<typeof NavLink> & {
-  title: ReactNode;
-  icon: Icon;
+type Props = {
+  className?: string | undefined;
 };
 
-const MobileMenuItem = ({
-  title,
-  icon: Icon,
-  ...props
-}: MobileMenuItemProps) => (
-  <li className="contents">
-    <NavLink
-      className="flex grow basis-0 flex-col items-center gap-2 py-4 outline-none transition duration-100 data-[focus-visible]:bg-black/5 data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 data-[selected]:bg-steel-900 data-[selected]:text-steel-50 dark:data-[selected]:bg-steel-50 dark:data-[selected]:text-steel-900"
-      {...props}
-    >
-      <Icon className="size-5" />
-      <div className="text-center text-xs font-medium">{title}</div>
-    </NavLink>
-  </li>
-);
+export const MobileMenu = ({ className }: Props) => {
+  const [isSupportDrawerOpen, setSupportDrawerOpen] = useState(false);
+  const openSupportDrawerOnClose = useRef(false);
+  const setOpenSupportDrawerOnClose = useCallback(() => {
+    openSupportDrawerOnClose.current = true;
+  }, []);
+  const maybeOpenSupportDrawer = useCallback(() => {
+    if (openSupportDrawerOnClose.current) {
+      setSupportDrawerOpen(true);
+      openSupportDrawerOnClose.current = false;
+    }
+  }, [setSupportDrawerOpen]);
+
+  return (
+    <>
+      <DrawerTrigger>
+        <Button
+          className={className ?? ""}
+          beforeIcon={List}
+          variant="ghost"
+          size="sm"
+          rounded
+          hideText
+        >
+          Menu
+        </Button>
+        <Drawer hideHeading title="Menu" onCloseFinish={maybeOpenSupportDrawer}>
+          <div className={styles.mobileMenu}>
+            <div className={styles.buttons}>
+              <Button
+                slot="close"
+                beforeIcon={Lifebuoy}
+                variant="ghost"
+                size="md"
+                rounded
+                onPress={setOpenSupportDrawerOnClose}
+              >
+                Support
+              </Button>
+              <Button
+                href="https://docs.pyth.network"
+                size="md"
+                rounded
+                target="_blank"
+              >
+                Dev Docs
+              </Button>
+            </div>
+            <div className={styles.theme}>
+              <span className={styles.themeLabel}>Theme</span>
+              <ThemeSwitch />
+            </div>
+          </div>
+        </Drawer>
+      </DrawerTrigger>
+      <SupportDrawer
+        isOpen={isSupportDrawerOpen}
+        onOpenChange={setSupportDrawerOpen}
+      />
+    </>
+  );
+};

+ 52 - 0
apps/insights/src/components/Root/mobile-nav-tabs.module.scss

@@ -0,0 +1,52 @@
+@use "@pythnetwork/component-library/theme";
+
+.mobileNavTabs {
+  background: theme.color("background", "primary");
+  border-top: 1px solid theme.color("border");
+  position: sticky;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: theme.spacing(2);
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: theme.spacing(2);
+
+  .mobileTab {
+    @include theme.text("xs", "medium");
+
+    text-align: center;
+    position: relative;
+    padding: theme.spacing(2);
+    color: theme.color("foreground");
+    text-decoration: none;
+    line-height: theme.spacing(5);
+    outline: none;
+    transition:
+      color 200ms linear,
+      background-color 100ms linear;
+    border-radius: theme.border-radius("full");
+    -webkit-tap-highlight-color: transparent;
+
+    .bubble {
+      position: absolute;
+      inset: 0;
+      border-radius: theme.border-radius("full");
+      background-color: theme.color("button", "solid", "background", "normal");
+      outline: 4px solid transparent;
+      outline-offset: 0;
+      z-index: -1;
+      transition-property: background-color, outline-color;
+      transition-duration: 100ms;
+      transition-timing-function: linear;
+    }
+
+    &[data-is-selected] {
+      color: theme.color("background", "primary");
+    }
+
+    &[data-pressed] {
+      background: theme.color("button", "outline", "background", "active");
+    }
+  }
+}

+ 62 - 0
apps/insights/src/components/Root/mobile-nav-tabs.tsx

@@ -0,0 +1,62 @@
+"use client";
+
+import { Link } from "@pythnetwork/component-library/unstyled/Link";
+import clsx from "clsx";
+import { motion } from "motion/react";
+import { usePathname } from "next/navigation";
+import { type ReactNode, useId, useMemo } from "react";
+
+import styles from "./mobile-nav-tabs.module.scss";
+
+type Props = {
+  className?: string | undefined;
+  tabs: Tab[];
+};
+
+type Tab = {
+  href: string;
+  children: ReactNode;
+};
+
+export const MobileNavTabs = ({ tabs, className }: Props) => {
+  const bubbleId = useId();
+
+  return (
+    <nav className={clsx(styles.mobileNavTabs, className)}>
+      {tabs.map((tab) => (
+        <NavTab tab={tab} key={tab.href} bubbleId={bubbleId} />
+      ))}
+    </nav>
+  );
+};
+
+type TabProps = {
+  tab: Tab;
+  bubbleId: string;
+};
+
+const NavTab = ({ tab, bubbleId }: TabProps) => {
+  const pathname = usePathname();
+  const isSelected = useMemo(
+    () => (tab.href === "/" ? pathname === "/" : pathname.startsWith(tab.href)),
+    [tab.href, pathname],
+  );
+
+  return (
+    <Link
+      href={tab.href}
+      className={styles.mobileTab ?? ""}
+      data-is-selected={isSelected ? "" : undefined}
+    >
+      {tab.children}
+      {isSelected && (
+        <motion.span
+          layoutId={`${bubbleId}-bubble`}
+          className={styles.bubble}
+          transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+          style={{ originY: "top" }}
+        />
+      )}
+    </Link>
+  );
+};

+ 0 - 30
apps/insights/src/components/Root/nav-link.tsx

@@ -1,30 +0,0 @@
-"use client";
-
-import { Link } from "@pythnetwork/component-library/unstyled/Link";
-import { useSelectedLayoutSegment } from "next/navigation";
-import type { ReactNode } from "react";
-
-type Props = {
-  href: string;
-  target?: string | undefined;
-  className?: string | undefined;
-  children?: ReactNode | ReactNode[] | undefined;
-};
-
-export const NavLink = ({ href, target, className, children }: Props) => {
-  const layoutSegment = useSelectedLayoutSegment();
-
-  return `/${layoutSegment ?? ""}` === href ? (
-    <div data-selected="" className={className}>
-      {children}
-    </div>
-  ) : (
-    <Link
-      href={href}
-      {...(target !== undefined && { target })}
-      {...(className !== undefined && { className })}
-    >
-      {children}
-    </Link>
-  );
-};

+ 8 - 7
apps/insights/src/components/Root/search-button.tsx

@@ -3,27 +3,28 @@
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { useMemo } from "react";
+import { type ComponentProps, useMemo } from "react";
 import { useIsSSR } from "react-aria";
 
 import { useToggleSearchDialog } from "./search-dialog";
 
-export const SearchButton = () => {
+type Props = ComponentProps<typeof Button>;
+
+export const SearchButton = (props: Props) => {
   const toggleSearchDialog = useToggleSearchDialog();
+
   return (
     <Button
       onPress={toggleSearchDialog}
       beforeIcon={MagnifyingGlass}
-      variant="outline"
       size="sm"
       rounded
-    >
-      <SearchText />
-    </Button>
+      {...props}
+    />
   );
 };
 
-const SearchText = () => {
+export const SearchShortcutText = () => {
   const isSSR = useIsSSR();
   return isSSR ? <Skeleton width={7} /> : <SearchTextImpl />;
 };

+ 159 - 54
apps/insights/src/components/Root/search-dialog.module.scss

@@ -15,88 +15,193 @@
     border-radius: theme.border-radius("2xl");
     padding: theme.spacing(1);
     max-height: theme.spacing(120);
+    width: min-content;
+    overflow: hidden;
+    display: flex;
+  }
+}
+
+.searchDialogContents {
+  gap: theme.spacing(1);
+  display: flex;
+  flex-flow: column nowrap;
+  overflow: hidden;
+  max-height: 100%;
+  min-height: 0;
+
+  .searchBar,
+  .left {
+    flex: none;
     display: flex;
     flex-flow: column nowrap;
-    flex-grow: 1;
-    gap: theme.spacing(1);
-    width: min-content;
 
-    .searchBar,
-    .left {
-      flex: none;
-      display: flex;
+    @include theme.breakpoint("sm") {
       flex-flow: row nowrap;
       align-items: center;
     }
+  }
+
+  .searchBar {
+    justify-content: space-between;
+    padding: theme.spacing(4);
+    flex: 1 0 0;
 
-    .searchBar {
-      justify-content: space-between;
+    @include theme.breakpoint("sm") {
+      flex: unset;
       padding: theme.spacing(1);
     }
 
-    .left {
-      gap: theme.spacing(2);
+    .searchInput {
+      @include theme.breakpoint("sm") {
+        width: theme.spacing(60);
+      }
+
+      @include theme.breakpoint("md") {
+        width: theme.spacing(70);
+      }
+
+      @include theme.breakpoint("lg") {
+        width: theme.spacing(90);
+      }
     }
+  }
+
+  .left {
+    gap: theme.spacing(2);
+
+    @include theme.breakpoint("sm") {
+      gap: theme.spacing(4);
+    }
+
+    .typeFilter {
+      & > * {
+        flex: 1 0 0;
+
+        @include theme.breakpoint("sm") {
+          flex: unset;
+        }
+      }
+    }
+  }
+
+  .closeButton {
+    display: none;
+
+    @include theme.breakpoint("sm") {
+      display: inline flow-root;
+    }
+  }
 
-    .body {
-      background: theme.color("background", "primary");
-      border-radius: theme.border-radius("xl");
+  .body {
+    background: theme.color("background", "primary");
+    border-radius: theme.border-radius("xl");
+    flex-grow: 1;
+    overflow: auto;
+    display: flex;
+
+    .listbox {
+      outline: none;
+      overflow: auto;
       flex-grow: 1;
-      overflow: hidden;
-      display: flex;
 
-      .listbox {
+      .item {
+        padding: theme.spacing(3) theme.spacing(4);
+        display: block;
+        cursor: pointer;
+        transition: background-color 100ms linear;
         outline: none;
-        overflow: auto;
-        flex-grow: 1;
-
-        .item {
-          padding: theme.spacing(3) theme.spacing(4);
-          display: flex;
-          flex-flow: row nowrap;
-          align-items: center;
-          cursor: pointer;
-          transition: background-color 100ms linear;
-          outline: none;
-          text-decoration: none;
-          border-top: 1px solid theme.color("background", "secondary");
+        text-decoration: none;
+        border-top: 1px solid theme.color("background", "secondary");
+        -webkit-tap-highlight-color: transparent;
 
-          &[data-is-first] {
+        &[data-is-first] {
+          @include theme.breakpoint("sm") {
             border-top: none;
           }
+        }
 
-          & > *:last-child {
-            flex-shrink: 0;
-          }
+        & > *:last-child {
+          flex-shrink: 0;
+        }
 
-          &[data-focused] {
-            background-color: theme.color(
-              "button",
-              "outline",
-              "background",
-              "hover"
-            );
-          }
+        &[data-focused] {
+          background-color: theme.color(
+            "button",
+            "outline",
+            "background",
+            "hover"
+          );
+        }
 
-          &[data-pressed] {
-            background-color: theme.color(
-              "button",
-              "outline",
-              "background",
-              "active"
-            );
+        &[data-pressed] {
+          background-color: theme.color(
+            "button",
+            "outline",
+            "background",
+            "active"
+          );
+        }
+
+        .itemType {
+          flex-shrink: 0;
+          margin-right: theme.spacing(6);
+        }
+
+        .itemTag {
+          flex-grow: 1;
+        }
+
+        .smallScreen {
+          display: flex;
+          flex-flow: column nowrap;
+          gap: theme.spacing(2);
+
+          @include theme.breakpoint("sm") {
+            display: none;
           }
 
-          .itemType {
-            flex-shrink: 0;
-            margin-right: theme.spacing(6);
+          .bottom {
+            flex-flow: column nowrap;
+            gap: theme.spacing(2);
+            display: flex;
+            margin: 0;
+
+            .field {
+              display: flex;
+              flex-flow: row nowrap;
+              justify-content: space-between;
+              gap: theme.spacing(4);
+              align-items: center;
+
+              dt {
+                color: theme.color("foreground");
+                font-weight: theme.font-weight("medium");
+                font-size: theme.font-size("sm");
+              }
+
+              dd {
+                margin: 0;
+              }
+            }
           }
+        }
 
-          .itemTag {
-            flex-grow: 1;
+        .largeScreen {
+          display: none;
+          flex-flow: row nowrap;
+          align-items: center;
+
+          @include theme.breakpoint("sm") {
+            display: flex;
           }
         }
       }
     }
   }
 }
+
+// stylelint-disable property-no-unknown
+:export {
+  breakpoint-sm: theme.map-get-strict(theme.$breakpoints, "sm");
+}
+// stylelint-enable property-no-unknown

+ 232 - 138
apps/insights/src/components/Root/search-dialog.tsx

@@ -3,6 +3,7 @@
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Button } from "@pythnetwork/component-library/Button";
+import { Drawer } from "@pythnetwork/component-library/Drawer";
 import { ModalDialog } from "@pythnetwork/component-library/ModalDialog";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
@@ -14,9 +15,11 @@ import {
   ListBox,
   ListBoxItem,
 } from "@pythnetwork/component-library/unstyled/ListBox";
+import { useMediaQuery } from "@react-hookz/web";
 import { useRouter } from "next/navigation";
 import {
   type ReactNode,
+  type ComponentProps,
   useState,
   useCallback,
   useEffect,
@@ -153,154 +156,245 @@ export const SearchDialogProvider = ({ children, publishers }: Props) => {
       <SearchDialogOpenContext value={searchDialogState}>
         {children}
       </SearchDialogOpenContext>
-      <ModalDialog
+      <SearchContainer
         key="search-modal"
         isOpen={searchDialogState.isOpen}
         onOpenChange={handleOpenChange}
-        overlayVariants={{
-          unmounted: { backgroundColor: "#00000000" },
-          hidden: { backgroundColor: "#00000000" },
-          visible: { backgroundColor: "#00000080" },
-        }}
-        overlayClassName={styles.modalOverlay ?? ""}
-        variants={{
-          visible: {
-            y: 0,
-            transition: { type: "spring", duration: 0.8, bounce: 0.35 },
-          },
-          hidden: {
-            y: "calc(-100% - 8rem)",
-            transition: { ease: "linear", duration: CLOSE_DURATION_IN_SECONDS },
-          },
-          unmounted: {
-            y: "calc(-100% - 8rem)",
-          },
-        }}
-        className={styles.searchMenu ?? ""}
-        aria-label="Search"
+        title="Search"
       >
-        <div className={styles.searchBar}>
-          <div className={styles.left}>
-            <SearchInput
-              size="md"
-              width={90}
-              placeholder="Asset symbol, publisher name or id"
-              value={search}
-              onChange={setSearch}
-              // eslint-disable-next-line jsx-a11y/no-autofocus
-              autoFocus
-            />
-            <SingleToggleGroup
-              selectedKey={type}
-              // @ts-expect-error react-aria coerces everything to Key for some reason...
-              onSelectionChange={setType}
-              items={[
-                { id: "", children: "All" },
-                { id: ResultType.PriceFeed, children: "Price Feeds" },
-                { id: ResultType.Publisher, children: "Publishers" },
-              ]}
-            />
-          </div>
-          <Button
-            className={styles.closeButton ?? ""}
-            beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-            slot="close"
-            hideText
-            rounded
-            variant="ghost"
-            size="sm"
-          >
-            Close
-          </Button>
-        </div>
-        <div className={styles.body}>
-          <RouterProvider navigate={handleOpenItem}>
-            <Virtualizer layout={new ListLayout()}>
-              <ListBox
-                aria-label="Search"
-                items={results}
-                className={styles.listbox ?? ""}
+        <div className={styles.searchDialogContents}>
+          <div className={styles.searchBar}>
+            <div className={styles.left}>
+              <SearchInput
+                size="md"
+                placeholder="Asset symbol, publisher name or id"
+                value={search}
+                onChange={setSearch}
+                className={styles.searchInput ?? ""}
                 // eslint-disable-next-line jsx-a11y/no-autofocus
-                autoFocus={false}
-                // @ts-expect-error looks like react-aria isn't exposing this
-                // property in the typescript types correctly...
-                shouldFocusOnHover
-                emptyState={
-                  <NoResults
-                    query={search}
-                    onClearSearch={() => {
-                      setSearch("");
-                    }}
-                  />
-                }
-              >
-                {(result) => (
-                  <ListBoxItem
-                    textValue={
-                      result.type === ResultType.PriceFeed
-                        ? result.displaySymbol
-                        : (result.name ?? result.publisherKey)
-                    }
-                    className={styles.item ?? ""}
-                    href={
-                      result.type === ResultType.PriceFeed
-                        ? `/price-feeds/${encodeURIComponent(result.id)}`
-                        : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
-                    }
-                    data-is-first={
-                      result.id === results[0]?.id ? "" : undefined
-                    }
-                  >
-                    <div className={styles.itemType}>
-                      <Badge
-                        variant={
-                          result.type === ResultType.PriceFeed
-                            ? "warning"
-                            : "info"
-                        }
-                        style="filled"
-                        size="xs"
-                      >
-                        {result.type === ResultType.PriceFeed
-                          ? "PRICE FEED"
-                          : "PUBLISHER"}
-                      </Badge>
-                    </div>
-                    {result.type === ResultType.PriceFeed ? (
-                      <>
-                        <PriceFeedTag
-                          compact
-                          symbol={result.id}
-                          className={styles.itemTag}
-                        />
-                        <AssetClassTag symbol={result.id} />
-                      </>
-                    ) : (
-                      <>
-                        <PublisherTag
-                          className={styles.itemTag}
-                          compact
-                          cluster={result.cluster}
-                          publisherKey={result.publisherKey}
-                          {...(result.name && {
-                            name: result.name,
-                            icon: result.icon,
-                          })}
-                        />
-                        <Score score={result.averageScore} />
-                      </>
-                    )}
-                  </ListBoxItem>
-                )}
-              </ListBox>
-            </Virtualizer>
-          </RouterProvider>
+                autoFocus
+              />
+              <SingleToggleGroup
+                selectedKey={type}
+                className={styles.typeFilter ?? ""}
+                // @ts-expect-error react-aria coerces everything to Key for some reason...
+                onSelectionChange={setType}
+                items={[
+                  { id: "", children: "All" },
+                  { id: ResultType.PriceFeed, children: "Price Feeds" },
+                  { id: ResultType.Publisher, children: "Publishers" },
+                ]}
+              />
+            </div>
+            <Button
+              className={styles.closeButton ?? ""}
+              beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+              slot="close"
+              hideText
+              rounded
+              variant="ghost"
+              size="sm"
+            >
+              Close
+            </Button>
+          </div>
+          <div className={styles.body}>
+            <RouterProvider navigate={handleOpenItem}>
+              <Virtualizer layout={new ListLayout()}>
+                <ListBox
+                  aria-label="Search"
+                  items={results}
+                  className={styles.listbox ?? ""}
+                  // eslint-disable-next-line jsx-a11y/no-autofocus
+                  autoFocus={false}
+                  // @ts-expect-error looks like react-aria isn't exposing this
+                  // property in the typescript types correctly...
+                  shouldFocusOnHover
+                  emptyState={
+                    <NoResults
+                      query={search}
+                      onClearSearch={() => {
+                        setSearch("");
+                      }}
+                    />
+                  }
+                >
+                  {(result) => (
+                    <ListBoxItem
+                      textValue={
+                        result.type === ResultType.PriceFeed
+                          ? result.displaySymbol
+                          : (result.name ?? result.publisherKey)
+                      }
+                      className={styles.item ?? ""}
+                      href={
+                        result.type === ResultType.PriceFeed
+                          ? `/price-feeds/${encodeURIComponent(result.id)}`
+                          : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
+                      }
+                      data-is-first={
+                        result.id === results[0]?.id ? "" : undefined
+                      }
+                    >
+                      <div className={styles.smallScreen}>
+                        {result.type === ResultType.PriceFeed ? (
+                          <PriceFeedTag
+                            compact
+                            symbol={result.id}
+                            className={styles.itemTag}
+                          />
+                        ) : (
+                          <PublisherTag
+                            className={styles.itemTag}
+                            compact
+                            cluster={result.cluster}
+                            publisherKey={result.publisherKey}
+                            {...(result.name && {
+                              name: result.name,
+                              icon: result.icon,
+                            })}
+                          />
+                        )}
+                        <dl className={styles.bottom}>
+                          <div className={styles.field}>
+                            <dt>Type</dt>
+                            <dd>
+                              <Badge
+                                variant={
+                                  result.type === ResultType.PriceFeed
+                                    ? "warning"
+                                    : "info"
+                                }
+                                style="filled"
+                                size="xs"
+                              >
+                                {result.type === ResultType.PriceFeed
+                                  ? "PRICE FEED"
+                                  : "PUBLISHER"}
+                              </Badge>
+                            </dd>
+                          </div>
+                          <div className={styles.field}>
+                            {result.type === ResultType.PriceFeed ? (
+                              <>
+                                <dt>Asset Class</dt>
+                                <dd>
+                                  <AssetClassTag
+                                    symbol={result.id}
+                                    className={styles.itemExtra ?? ""}
+                                  />
+                                </dd>
+                              </>
+                            ) : (
+                              <>
+                                <dt>Average Score</dt>
+                                <dd>
+                                  <Score
+                                    score={result.averageScore}
+                                    className={styles.itemExtra ?? ""}
+                                  />
+                                </dd>
+                              </>
+                            )}
+                          </div>
+                        </dl>
+                      </div>
+                      <div className={styles.largeScreen}>
+                        <div className={styles.itemType}>
+                          <Badge
+                            variant={
+                              result.type === ResultType.PriceFeed
+                                ? "warning"
+                                : "info"
+                            }
+                            style="filled"
+                            size="xs"
+                          >
+                            {result.type === ResultType.PriceFeed
+                              ? "PRICE FEED"
+                              : "PUBLISHER"}
+                          </Badge>
+                        </div>
+                        {result.type === ResultType.PriceFeed ? (
+                          <>
+                            <PriceFeedTag
+                              compact
+                              symbol={result.id}
+                              className={styles.itemTag}
+                            />
+                            <AssetClassTag
+                              symbol={result.id}
+                              className={styles.itemExtra ?? ""}
+                            />
+                          </>
+                        ) : (
+                          <>
+                            <PublisherTag
+                              className={styles.itemTag}
+                              compact
+                              cluster={result.cluster}
+                              publisherKey={result.publisherKey}
+                              {...(result.name && {
+                                name: result.name,
+                                icon: result.icon,
+                              })}
+                            />
+                            <Score
+                              score={result.averageScore}
+                              className={styles.itemExtra ?? ""}
+                            />
+                          </>
+                        )}
+                      </div>
+                    </ListBoxItem>
+                  )}
+                </ListBox>
+              </Virtualizer>
+            </RouterProvider>
+          </div>
         </div>
-      </ModalDialog>
+      </SearchContainer>
     </>
   );
 };
 
+const SearchContainer = (
+  props: ComponentProps<typeof Drawer> & { title: string },
+) => {
+  const isLarge = useMediaQuery(
+    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
+  );
+
+  return isLarge ? (
+    <ModalDialog
+      overlayVariants={{
+        unmounted: { backgroundColor: "#00000000" },
+        hidden: { backgroundColor: "#00000000" },
+        visible: { backgroundColor: "#00000080" },
+      }}
+      overlayClassName={styles.modalOverlay ?? ""}
+      className={styles.searchMenu ?? ""}
+      variants={{
+        visible: {
+          y: 0,
+          transition: { type: "spring", duration: 0.8, bounce: 0.35 },
+        },
+        hidden: {
+          y: "calc(-100% - 8rem)",
+          transition: { ease: "linear", duration: CLOSE_DURATION_IN_SECONDS },
+        },
+        unmounted: {
+          y: "calc(-100% - 8rem)",
+        },
+      }}
+      aria-label={props.title}
+      {...props}
+    />
+  ) : (
+    <Drawer fill hideHeading {...props} />
+  );
+};
+
 enum ResultType {
   PriceFeed,
   Publisher,

+ 2 - 0
apps/insights/src/components/Root/support-drawer.module.scss

@@ -58,6 +58,8 @@
           color: theme.color("muted");
           grid-column: 2;
           grid-row: 2;
+          text-overflow: ellipsis;
+          overflow: hidden;
         }
 
         .caret {

+ 71 - 76
apps/insights/src/components/Root/support-drawer.tsx

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

+ 2 - 19
apps/insights/src/components/Root/tabs.tsx

@@ -19,28 +19,11 @@ export const TabRoot = (
 };
 
 export const MainNavTabs = (
-  props: Omit<
-    ComponentProps<typeof MainNavTabsComponent>,
-    "pathname" | "items"
-  >,
+  props: Omit<ComponentProps<typeof MainNavTabsComponent>, "pathname">,
 ) => {
   const pathname = usePathname();
 
-  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}
-    />
-  );
+  return <MainNavTabsComponent pathname={pathname} {...props} />;
 };
 
 export const TabPanel = ({

+ 5 - 3
apps/insights/src/components/Score/index.tsx

@@ -2,6 +2,7 @@
 
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { Meter } from "@pythnetwork/component-library/unstyled/Meter";
+import clsx from "clsx";
 import type { CSSProperties } from "react";
 
 import styles from "./index.module.scss";
@@ -11,6 +12,7 @@ const SCORE_WIDTH = 24;
 type Props = {
   width?: number | undefined;
   fill?: boolean | undefined;
+  className?: string | undefined;
 } & (
   | { isLoading: true }
   | {
@@ -19,10 +21,10 @@ type Props = {
     }
 );
 
-export const Score = ({ width, fill, ...props }: Props) =>
+export const Score = ({ width, fill, className, ...props }: Props) =>
   props.isLoading ? (
     <Skeleton
-      className={styles.score}
+      className={clsx(className, styles.score)}
       fill
       data-fill={fill ? "" : undefined}
       {...(!fill && {
@@ -31,7 +33,7 @@ export const Score = ({ width, fill, ...props }: Props) =>
     />
   ) : (
     <Meter
-      className={styles.meter ?? ""}
+      className={clsx(className, styles.meter)}
       value={props.score}
       maxValue={1}
       aria-label="Score"

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

@@ -302,6 +302,7 @@ export const getPublisherAverageScoreHistory = async (
             FROM publisher_quality_ranking
             WHERE publisher = {key: String}
             AND cluster = {cluster: String}
+            AND interval_days = 1
             GROUP BY time
             ORDER BY time DESC
             LIMIT 30

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

@@ -6,6 +6,7 @@ export const priceFeeds = {
     "Equity.US.NFLX/USD",
     "Commodities.WTI1M",
     "Crypto.1INCH/USD",
+    "Equity.US.META/USD",
   ],
   featuredComingSoon: ["Rates.US1Y"],
 };

+ 10 - 6
packages/component-library/.storybook/preview.tsx

@@ -2,10 +2,11 @@ import { sans } from "@pythnetwork/fonts";
 import { withThemeByClassName } from "@storybook/addon-themes";
 import type { Preview, Decorator } from "@storybook/react";
 import clsx from "clsx";
+import { useState } from "react";
 
 import "../src/Html/base.scss";
 import styles from "./storybook.module.scss";
-import { OverlayVisibleContextProvider } from "../src/overlay-visible-context.js";
+import { OverlayVisibleContext } from "../src/overlay-visible-context.js";
 
 const preview = {
   parameters: {
@@ -29,11 +30,14 @@ const preview = {
 export default preview;
 
 export const decorators: Decorator[] = [
-  (Story) => (
-    <OverlayVisibleContextProvider>
-      <Story />
-    </OverlayVisibleContextProvider>
-  ),
+  (Story) => {
+    const overlayVisibleState = useState(false);
+    return (
+      <OverlayVisibleContext value={overlayVisibleState}>
+        <Story />
+      </OverlayVisibleContext>
+    );
+  },
   withThemeByClassName({
     themes: {
       Light: clsx(sans.className, styles.light),

+ 10 - 2
packages/component-library/src/Breadcrumbs/index.module.scss

@@ -4,16 +4,24 @@
   display: flex;
   flex-flow: row nowrap;
   align-items: center;
-  gap: theme.spacing(4);
+  gap: theme.spacing(2);
   list-style: none;
   margin: 0;
   padding: 0;
 
+  @include theme.breakpoint("sm") {
+    gap: theme.spacing(4);
+  }
+
   .breadcrumb {
     display: flex;
     flex-flow: row nowrap;
     align-items: center;
-    gap: theme.spacing(4);
+    gap: theme.spacing(2);
+
+    @include theme.breakpoint("sm") {
+      gap: theme.spacing(4);
+    }
 
     .separator {
       color: theme.color("muted");

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

@@ -12,6 +12,8 @@
   text-decoration: none;
   outline-offset: 0;
   outline: theme.spacing(1) solid transparent;
+  text-align: center;
+  -webkit-tap-highlight-color: transparent;
 
   .iconWrapper {
     display: inline-grid;

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

@@ -16,6 +16,7 @@
   position: relative;
   padding: theme.spacing(1);
   isolation: isolate;
+  -webkit-tap-highlight-color: transparent;
 
   @at-root button#{&} {
     cursor: pointer;
@@ -34,8 +35,10 @@
 
   .header {
     display: flex;
-    padding: theme.spacing(3) theme.spacing(4);
+
+    // padding: theme.spacing(3) theme.spacing(4);
     position: relative;
+    flex-flow: column nowrap;
 
     .title {
       color: theme.color("heading");
@@ -43,6 +46,7 @@
       flex-flow: row nowrap;
       gap: theme.spacing(3);
       align-items: center;
+      padding: theme.spacing(3);
 
       @include theme.text("lg", "medium");
 
@@ -54,14 +58,30 @@
     }
 
     .toolbar {
-      position: absolute;
-      right: theme.spacing(3);
-      top: 0;
-      bottom: theme.spacing(0);
       display: flex;
       flex-flow: row nowrap;
-      gap: theme.spacing(4);
+      gap: theme.spacing(2);
       align-items: center;
+      justify-content: center;
+      padding: theme.spacing(1.5);
+
+      @include theme.breakpoint("lg") {
+        position: absolute;
+        right: theme.spacing(3);
+        top: 0;
+        bottom: theme.spacing(0);
+        gap: theme.spacing(4);
+        justify-content: unset;
+        padding: 0;
+      }
+
+      &[data-always-on-top] {
+        position: absolute;
+        right: theme.spacing(3);
+        top: 0;
+        bottom: theme.spacing(0);
+        gap: theme.spacing(4);
+      }
     }
   }
 

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

@@ -22,6 +22,8 @@ type OwnProps = {
   toolbar?: ReactNode | ReactNode[] | undefined;
   footer?: ReactNode | undefined;
   nonInteractive?: boolean | undefined;
+  toolbarClassName?: string | undefined;
+  toolbarAlwaysOnTop?: boolean | undefined;
 };
 
 export type Props<T extends ElementType> = Omit<
@@ -59,6 +61,8 @@ const cardProps = <T extends ElementType>({
   title,
   toolbar,
   footer,
+  toolbarClassName,
+  toolbarAlwaysOnTop,
   ...props
 }: Props<T>) => ({
   ...props,
@@ -73,7 +77,14 @@ const cardProps = <T extends ElementType>({
             {icon && <div className={styles.icon}>{icon}</div>}
             {title}
           </h2>
-          <div className={styles.toolbar}>{toolbar}</div>
+          {toolbar && (
+            <div
+              className={clsx(styles.toolbar, toolbarClassName)}
+              data-always-on-top={toolbarAlwaysOnTop ? "" : undefined}
+            >
+              {toolbar}
+            </div>
+          )}
         </div>
       )}
       {children}

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

@@ -11,6 +11,7 @@ const AnimatedPanel = motion(UnstyledTabPanel);
 type Props = {
   items: {
     id: string;
+    className?: string;
     children: ReactNode;
   }[];
 };

+ 94 - 16
packages/component-library/src/Drawer/index.module.scss

@@ -3,39 +3,88 @@
 .modalOverlay {
   position: fixed;
   inset: 0;
-  background: rgba(from black r g b / 30%);
+  background: rgba(from black r g b / 50%);
   z-index: 1;
 
+  @include theme.breakpoint("sm") {
+    background: rgba(from black r g b / 30%);
+  }
+
   .drawer {
     position: fixed;
-    top: theme.spacing(4);
-    bottom: theme.spacing(4);
-    right: theme.spacing(4);
-    width: 60%;
-    max-width: theme.spacing(180);
+    bottom: 0;
+    left: 1px;
+    right: 1px;
+    max-height: 90%;
     outline: none;
     background: theme.color("background", "primary");
     border: 1px solid theme.color("border");
-    border-radius: theme.border-radius("3xl");
+    border-top-left-radius: theme.border-radius("3xl");
+    border-top-right-radius: theme.border-radius("3xl");
     display: flex;
     flex-flow: column nowrap;
     overflow-y: hidden;
-    padding-bottom: theme.border-radius("3xl");
+
+    @include theme.breakpoint("sm") {
+      top: theme.spacing(4);
+      bottom: theme.spacing(4);
+      left: unset;
+      right: theme.spacing(4);
+      width: 60%;
+      max-width: theme.spacing(180);
+      max-height: unset;
+      border-radius: theme.border-radius("3xl");
+      padding-bottom: theme.border-radius("3xl");
+    }
+
+    .handle {
+      padding: theme.spacing(3) 0;
+      touch-action: none;
+      cursor: pointer;
+      -webkit-tap-highlight-color: transparent;
+
+      @include theme.breakpoint("sm") {
+        display: none;
+      }
+
+      &::after {
+        display: block;
+        content: "";
+        border-radius: theme.border-radius("full");
+        background: theme.color("background", "secondary");
+        width: theme.spacing(18);
+        height: theme.spacing(1.5);
+        margin: 0 auto;
+        transition: background 40ms linear;
+      }
+
+      &[data-is-pressed]::after {
+        background: theme.color("muted");
+      }
+    }
 
     .heading {
-      padding: theme.spacing(4);
-      padding-left: theme.spacing(6);
       display: flex;
-      flex-flow: row nowrap;
-      justify-content: space-between;
-      align-items: center;
-      color: theme.color("heading");
+      padding: theme.spacing(4);
+      flex-flow: column nowrap;
       flex: none;
-      border-bottom: 1px solid theme.color("border");
+      gap: theme.spacing(2);
+
+      .headingTop {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+      }
+
+      @include theme.breakpoint("sm") {
+        border-bottom: 1px solid theme.color("border");
+        padding-left: theme.spacing(6);
+      }
 
       .title {
         @include theme.h4;
 
+        color: theme.color("heading");
         display: flex;
         flex-flow: row nowrap;
         gap: theme.spacing(3);
@@ -46,13 +95,26 @@
         flex-flow: row nowrap;
         gap: theme.spacing(3);
         align-items: center;
+
+        .closeButton {
+          display: none;
+
+          @include theme.breakpoint("sm") {
+            display: unset;
+          }
+        }
       }
     }
 
     .body {
+      display: grid;
       flex: 1;
       overflow-y: auto;
-      padding: theme.spacing(6);
+      padding: theme.spacing(4);
+
+      @include theme.breakpoint("sm") {
+        padding: theme.spacing(6);
+      }
     }
 
     &[data-fill] {
@@ -73,5 +135,21 @@
         padding: theme.spacing(4);
       }
     }
+
+    &[data-hide-heading] {
+      .heading {
+        display: none;
+
+        @include theme.breakpoint("sm") {
+          display: flex;
+        }
+      }
+    }
   }
 }
+
+// stylelint-disable property-no-unknown
+:export {
+  breakpoint-sm: theme.map-get-strict(theme.$breakpoints, "sm");
+}
+// stylelint-enable property-no-unknown

+ 179 - 58
packages/component-library/src/Drawer/index.tsx

@@ -1,12 +1,21 @@
 "use client";
 
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import { useMediaQuery } from "@react-hookz/web";
 import clsx from "clsx";
-import type { ComponentProps, ReactNode } from "react";
+import { animate, useMotionValue, useMotionValueEvent } from "motion/react";
+import {
+  type ComponentProps,
+  type ReactNode,
+  useState,
+  useRef,
+  useEffect,
+} from "react";
 import { Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
+import { useMainContentOffset } from "../MainContent/index.js";
 import { ModalDialog } from "../ModalDialog/index.js";
 
 export { ModalDialogTrigger as DrawerTrigger } from "../ModalDialog/index.js";
@@ -20,9 +29,11 @@ type OwnProps = {
   closeHref?: string | undefined;
   footer?: ReactNode | undefined;
   headingExtra?: ReactNode | undefined;
+  headingAfter?: ReactNode | undefined;
   headingClassName?: string | undefined;
   bodyClassName?: string | undefined;
   footerClassName?: string | undefined;
+  hideHeading?: boolean | undefined;
 };
 
 type Props = Omit<
@@ -42,62 +53,172 @@ export const Drawer = ({
   bodyClassName,
   footerClassName,
   headingExtra,
+  headingAfter,
+  hideHeading,
   ...props
-}: Props) => (
-  <ModalDialog
-    overlayVariants={{
-      unmounted: { backgroundColor: "#00000000" },
-      hidden: { backgroundColor: "#00000000" },
-      visible: { backgroundColor: "#00000080" },
-    }}
-    overlayClassName={styles.modalOverlay ?? ""}
-    variants={{
-      visible: {
-        x: 0,
-        transition: { type: "spring", duration: 1, bounce: 0.35 },
-      },
-      hidden: {
-        x: "calc(100% + 1rem)",
-        transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-      },
-      unmounted: {
-        x: "calc(100% + 1rem)",
-      },
-    }}
-    className={clsx(styles.drawer, className)}
-    data-has-footer={footer === undefined ? undefined : ""}
-    data-fill={fill ? "" : undefined}
-    {...props}
-  >
-    {(...args) => (
-      <>
-        <div className={clsx(styles.heading, headingClassName)}>
-          <Heading className={styles.title} slot="title">
-            {title}
-          </Heading>
-          <div className={styles.headingEnd}>
-            {headingExtra}
-            <Button
-              className={styles.closeButton ?? ""}
-              beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-              slot="close"
-              hideText
-              rounded
-              variant="ghost"
-              size="sm"
-              {...(closeHref && { href: closeHref })}
-            >
-              Close
-            </Button>
+}: Props) => {
+  const [, setMainContentOffset] = useMainContentOffset();
+  const modalRef = useRef<null | HTMLDivElement>(null);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isHandlePressed, setIsHandlePressed] = useState(false);
+  const isLarge = useMediaQuery(
+    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
+  );
+  const y = useMotionValue("100%");
+
+  useMotionValueEvent(y, "change", (y) => {
+    if (typeof y === "string") {
+      setMainContentOffset(100 - Number.parseInt(y.replace(/%$/, ""), 10));
+    } else if (modalRef.current) {
+      setMainContentOffset(100 - (100 * y) / modalRef.current.offsetHeight);
+    }
+  });
+
+  return (
+    <ModalDialog
+      ref={modalRef}
+      overlayVariants={{
+        unmounted: { backgroundColor: "#00000000" },
+        hidden: { backgroundColor: "#00000000" },
+        visible: { backgroundColor: "#00000080" },
+      }}
+      overlayClassName={styles.modalOverlay ?? ""}
+      variants={
+        isLarge
+          ? {
+              visible: {
+                x: 0,
+                transition: { type: "spring", duration: 1, bounce: 0.35 },
+              },
+              hidden: {
+                x: "calc(100% + 1rem)",
+                transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+              },
+              unmounted: {
+                x: "calc(100% + 1rem)",
+              },
+            }
+          : {
+              visible: {
+                y: 0,
+                transition: {
+                  duration: 0.5,
+                  ease: [0.32, 0.72, 0, 1],
+                },
+              },
+              hidden: {
+                y: "100%",
+                transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+              },
+              unmounted: {
+                y: "100%",
+              },
+            }
+      }
+      {...(!isLarge && {
+        style: { y },
+        drag: "y",
+        dragConstraints: { top: 0 },
+        dragElastic: false,
+        dragPropagation: true,
+        onDragStart: () => {
+          setIsDragging(true);
+        },
+        onDragEnd: (e, { velocity }, { state }) => {
+          setIsDragging(false);
+          if (e.type !== "pointercancel" && velocity.y > 10) {
+            state.close();
+          } else {
+            // eslint-disable-next-line @typescript-eslint/no-floating-promises
+            animate(y, "0", {
+              type: "inertia",
+              bounceStiffness: 300,
+              bounceDamping: 40,
+              timeConstant: 300,
+              min: 0,
+              max: 0,
+            });
+          }
+        },
+      })}
+      className={clsx(styles.drawer, className)}
+      data-has-footer={footer === undefined ? undefined : ""}
+      data-fill={fill ? "" : undefined}
+      data-hide-heading={hideHeading ? "" : undefined}
+      {...props}
+    >
+      {(...args) => (
+        <>
+          <OnResize
+            threshold={styles["breakpoint-sm"]}
+            onResize={() => {
+              setMainContentOffset(0);
+              args[0].state.close();
+            }}
+          />
+          <div
+            className={styles.handle}
+            onPointerDown={() => {
+              setIsHandlePressed(true);
+            }}
+            onPointerUp={() => {
+              setIsHandlePressed(false);
+            }}
+            data-is-pressed={isHandlePressed || isDragging ? "" : undefined}
+          />
+          <div className={clsx(styles.heading, headingClassName)}>
+            <div className={styles.headingTop}>
+              <Heading className={styles.title} slot="title">
+                {title}
+              </Heading>
+              <div className={styles.headingEnd}>
+                {headingExtra}
+                <Button
+                  className={styles.closeButton ?? ""}
+                  beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+                  slot="close"
+                  hideText
+                  rounded
+                  variant="ghost"
+                  size="sm"
+                  {...(closeHref && { href: closeHref })}
+                >
+                  Close
+                </Button>
+              </div>
+            </div>
+            {headingAfter}
           </div>
-        </div>
-        <div className={clsx(styles.body, bodyClassName)}>
-          {typeof children === "function" ? children(...args) : children}
-        </div>
-        {footer && (
-          <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
-        )}
-      </>
-    )}
-  </ModalDialog>
-);
+          <div className={clsx(styles.body, bodyClassName)}>
+            {typeof children === "function" ? children(...args) : children}
+          </div>
+          {footer && (
+            <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
+          )}
+        </>
+      )}
+    </ModalDialog>
+  );
+};
+
+type OnResizeProps = {
+  threshold: string | undefined;
+  onResize: () => void;
+};
+
+const OnResize = ({ threshold, onResize }: OnResizeProps) => {
+  const isAboveThreshold = useMediaQuery(`(min-width: ${threshold ?? ""})`, {
+    initializeWithValue: false,
+  });
+  const previousValue = useRef<boolean | undefined>(undefined);
+  useEffect(() => {
+    if (previousValue.current === undefined) {
+      previousValue.current = isAboveThreshold;
+    } else if (isAboveThreshold !== previousValue.current) {
+      previousValue.current = isAboveThreshold;
+      onResize();
+    }
+  }, [isAboveThreshold, onResize]);
+  // eslint-disable-next-line unicorn/no-null
+  return null;
+};

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

@@ -2,8 +2,7 @@
 @use "../theme";
 
 :root {
-  color: theme.color("foreground");
-  background: theme.color("background", "primary");
+  background: black;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   scroll-behavior: smooth;
@@ -11,26 +10,7 @@
 }
 
 html {
-  // We use `scrollbar-gutter: stable` which prevents the page from jumping when
-  // adding or removing the scrollbar.  However, react-aria [tries to add a
-  // padding](https://github.com/adobe/react-spectrum/issues/5470) to the html
-  // element when opening/closing popovers and does not account for
-  // `scrollbar-gutter`, and there's no way (yet) to disable that behavior.
-  // Forcing the padding to zero here effectively prevents that behavior from
-  // causing the page to jump.
-  // TODO Remove this when a fix for
-  // https://github.com/adobe/react-spectrum/issues/5470 lands in react-aria
-  scrollbar-gutter: stable;
   padding-right: 0 !important;
-
-  // We also have to disable `scrollbar-gutter: stable` when overlays are
-  // visible, because chrome leaves an unsightly gap rather than letting the
-  // modal backgrop fill the page even though it's fixed position.
-  &[data-overlay-visible] {
-    scrollbar-gutter: auto;
-    padding-right: var(--scrollbar-width) !important;
-    overflow: hidden;
-  }
 }
 
 *::selection {

+ 3 - 52
packages/component-library/src/Html/index.tsx

@@ -1,58 +1,9 @@
-"use client";
-
 import { sans } from "@pythnetwork/fonts";
 import clsx from "clsx";
-import {
-  type ComponentProps,
-  type CSSProperties,
-  useState,
-  useEffect,
-} from "react";
-
-import {
-  OverlayVisibleContextProvider,
-  useIsOverlayVisible,
-} from "../overlay-visible-context.js";
+import type { ComponentProps } from "react";
 
 import "./base.scss";
 
-export const Html = (props: ComponentProps<"html">) => (
-  <OverlayVisibleContextProvider>
-    <HtmlInner {...props} />
-  </OverlayVisibleContextProvider>
+export const Html = ({ className, lang, ...props }: ComponentProps<"html">) => (
+  <html lang={lang} className={clsx(sans.className, className)} {...props} />
 );
-
-const HtmlInner = ({ className, lang, ...props }: ComponentProps<"html">) => {
-  const isOverlayVisible = useIsOverlayVisible();
-  const scrollbarWidth = useScrollbarWidth();
-
-  return (
-    <html
-      lang={lang}
-      className={clsx(sans.className, className)}
-      style={
-        {
-          "--scrollbar-width": `${scrollbarWidth.toString()}px`,
-        } as CSSProperties
-      }
-      data-overlay-visible={isOverlayVisible ? "" : undefined}
-      {...props}
-    />
-  );
-};
-
-const DEFAULT_SCROLLBAR_WIDTH = 0;
-
-const useScrollbarWidth = () => {
-  const [scrollbarWidth, setScrollbarWidth] = useState(DEFAULT_SCROLLBAR_WIDTH);
-
-  useEffect(() => {
-    const scrollDiv = document.createElement("div");
-    scrollDiv.style.overflow = "scroll";
-    document.body.append(scrollDiv);
-    setScrollbarWidth(scrollDiv.offsetWidth - scrollDiv.clientWidth);
-    scrollDiv.remove();
-  }, []);
-
-  return scrollbarWidth;
-};

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

@@ -1,7 +1,6 @@
 @use "../theme";
 
 .infoBox {
-  grid-column: span 2 / span 2;
   background: theme.color("states", "info", "background");
   padding: theme.spacing(4);
   border-radius: theme.border-radius("xl");

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

@@ -0,0 +1,11 @@
+@use "../theme";
+
+.mainContent {
+  color: theme.color("foreground");
+  background: theme.color("background", "primary");
+  border-top-left-radius: calc(var(--offset) * theme.border-radius("xl"));
+  border-top-right-radius: calc(var(--offset) * theme.border-radius("xl"));
+  overflow: hidden auto;
+  transform: scale(calc(100% - (var(--offset) * 5%)));
+  height: 100dvh;
+}

+ 57 - 0
packages/component-library/src/MainContent/index.tsx

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

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

@@ -39,6 +39,7 @@
 
   &[data-selectable] .tab[data-selected] {
     pointer-events: auto;
+    -webkit-tap-highlight-color: transparent;
 
     &[data-hovered] .bubble {
       background-color: theme.color("button", "solid", "background", "hover");

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

@@ -1,6 +1,6 @@
 "use client";
 
-import { motion } from "motion/react";
+import { motion, type PanInfo } from "motion/react";
 import {
   type ComponentProps,
   type Dispatch,
@@ -12,6 +12,7 @@ import {
   useEffect,
 } from "react";
 import {
+  type ModalRenderProps,
   Modal,
   ModalOverlay,
   Dialog,
@@ -79,6 +80,11 @@ type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> &
       | ComponentProps<typeof MotionModalOverlay>["variants"]
       | undefined;
     onCloseFinish?: (() => void) | undefined;
+    onDragEnd?: (
+      e: MouseEvent | TouchEvent | PointerEvent,
+      panInfo: PanInfo,
+      modalState: ModalRenderProps,
+    ) => void;
   };
 
 type Props = Omit<ComponentProps<typeof MotionDialog>, keyof OwnProps> &
@@ -91,6 +97,7 @@ export const ModalDialog = ({
   overlayClassName,
   overlayVariants,
   children,
+  onDragEnd,
   ...props
 }: Props) => {
   const contextAnimationState = use(ModalAnimationContext);
@@ -142,7 +149,14 @@ export const ModalDialog = ({
     >
       <Modal style={{ height: 0 }}>
         {(...args) => (
-          <MotionDialog {...props}>
+          <MotionDialog
+            {...props}
+            {...(onDragEnd && {
+              onDragEnd: (e, info) => {
+                onDragEnd(e, info, args[0]);
+              },
+            })}
+          >
             {typeof children === "function" ? children(...args) : children}
           </MotionDialog>
         )}

+ 10 - 2
packages/component-library/src/Paginator/index.module.scss

@@ -3,14 +3,22 @@
 .paginator {
   display: flex;
   flex-flow: row nowrap;
-  justify-content: space-between;
+  justify-content: center;
+
+  @include theme.breakpoint("sm") {
+    justify-content: space-between;
+  }
 
   .pageSizeSelect {
-    display: flex;
+    display: none;
     flex-flow: row nowrap;
     align-items: center;
     gap: theme.spacing(1);
 
+    @include theme.breakpoint("sm") {
+      display: flex;
+    }
+
     .loadingIndicator {
       width: theme.spacing(4);
       height: theme.spacing(4);

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

@@ -6,9 +6,12 @@
   gap: theme.spacing(2);
   position: relative;
   display: inline-block;
-  width: calc(theme.spacing(1) * var(--width));
   color: theme.color("button", "outline", "foreground");
 
+  &[data-static-width] {
+    width: calc(theme.spacing(1) * var(--width));
+  }
+
   .input {
     display: inline-block;
     width: 100%;

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

@@ -16,7 +16,7 @@ export const SIZES = ["xs", "sm", "md", "lg"] as const;
 type Props = ComponentProps<typeof SearchField> & {
   label?: string | undefined;
   size?: (typeof SIZES)[number] | undefined;
-  width: number;
+  width?: number | undefined;
   isPending?: boolean | undefined;
   placeholder?: string;
 };
@@ -33,8 +33,9 @@ export const SearchInput = ({
   <SearchField
     aria-label={label ?? "Search"}
     className={clsx(styles.searchInput, className)}
-    style={{ "--width": width } as CSSProperties}
     data-size={size}
+    data-static-width={width === undefined ? undefined : ""}
+    {...(width && { style: { "--width": width } as CSSProperties })}
     {...(isPending && { "data-pending": "" })}
     {...props}
   >

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

@@ -27,6 +27,7 @@
 
       &[data-selectable] {
         pointer-events: auto;
+        -webkit-tap-highlight-color: transparent;
 
         &[data-hovered] .bubble {
           background-color: theme.color(

+ 9 - 2
packages/component-library/src/TabList/index.module.scss

@@ -4,15 +4,22 @@
   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);
 
+    @include theme.max-width;
+
     .tab {
       position: relative;
+      flex: 1 0 0;
+      width: 0;
+
+      @include theme.breakpoint("sm") {
+        flex: unset;
+        width: unset;
+      }
 
       .underline {
         position: absolute;

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

@@ -164,6 +164,7 @@
         outline: theme.spacing(0.5) solid transparent;
         outline-offset: -#{theme.spacing(0.5)};
         transition: outline-color 100ms linear;
+        -webkit-tap-highlight-color: transparent;
 
         &[data-focus-visible] {
           outline: theme.spacing(0.5) solid theme.color("focus");

+ 0 - 10
packages/component-library/src/overlay-visible-context.tsx

@@ -1,9 +1,7 @@
 import {
-  type ComponentProps,
   type Dispatch,
   type SetStateAction,
   createContext,
-  useState,
   useCallback,
   use,
 } from "react";
@@ -12,13 +10,6 @@ export const OverlayVisibleContext = createContext<
   [boolean, Dispatch<SetStateAction<boolean>>] | undefined
 >(undefined);
 
-export const OverlayVisibleContextProvider = (
-  props: Omit<ComponentProps<typeof OverlayVisibleContext>, "value">,
-) => {
-  const overlayVisibleState = useState(false);
-  return <OverlayVisibleContext value={overlayVisibleState} {...props} />;
-};
-
 const useOverlayVisible = () => {
   const overlayVisible = use(OverlayVisibleContext);
   if (overlayVisible === undefined) {
@@ -27,7 +18,6 @@ const useOverlayVisible = () => {
   return overlayVisible;
 };
 
-export const useIsOverlayVisible = () => useOverlayVisible()[0];
 export const useSetOverlayVisible = () => {
   const setOverlayVisible = useOverlayVisible()[1];
   return {

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

@@ -719,16 +719,24 @@ $button-sizes: (
   }
 }
 
-$max-width: 96rem;
+$max-width: spacing(372);
+$max-width-padding: var(--max-width-padding);
 
 @mixin max-width {
-  margin: 0 auto;
-  max-width: min(
-    $max-width,
-    calc(200vw - spacing(12) - 100% - var(--scrollbar-width))
-  );
-  padding: 0 spacing(6);
-  box-sizing: content-box;
+  & {
+    --max-width-padding: #{spacing(4)};
+
+    margin-left: auto;
+    margin-right: auto;
+    padding-left: $max-width-padding;
+    padding-right: $max-width-padding;
+    width: 100%;
+    max-width: $max-width;
+  }
+
+  @include breakpoint("sm") {
+    --max-width-padding: #{spacing(6)};
+  }
 }
 
 @mixin row {
@@ -770,12 +778,14 @@ $elevations: (
 }
 
 @mixin h3 {
-  font-size: font-size("2xl");
-  font-style: normal;
-  font-weight: font-weight("medium");
+  @include text("xl", "semibold");
+
   line-height: 125%;
   letter-spacing: letter-spacing("tighter");
-  margin: 0;
+
+  @include breakpoint("sm") {
+    font-size: font-size("2xl");
+  }
 }
 
 @mixin h4 {
@@ -794,3 +804,17 @@ $elevations: (
   font-style: normal;
   line-height: 1;
 }
+
+$breakpoints: (
+  "sm": 640px,
+  "md": 768px,
+  "lg": 1024px,
+  "xl": 1280px,
+  "2xl": 1536px,
+);
+
+@mixin breakpoint($point) {
+  @media (min-width: map-get-strict($breakpoints, $point)) {
+    @content;
+  }
+}

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

@@ -0,0 +1,3 @@
+"use client";
+
+export { GridList, GridListItem } from "react-aria-components";

+ 6 - 0
packages/component-library/stylelint.config.js

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

+ 4 - 0
packages/next-root/scss.d.ts

@@ -0,0 +1,4 @@
+declare module "*.scss" {
+  const content: Record<string, string>;
+  export = content;
+}

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

@@ -1,5 +1,6 @@
 import { GoogleAnalytics } from "@next/third-parties/google";
 import { LoggerProvider } from "@pythnetwork/app-logger/provider";
+import { MainContent } from "@pythnetwork/component-library/MainContent";
 import dynamic from "next/dynamic";
 import { ThemeProvider } from "next-themes";
 import type { ComponentProps, ReactNode } from "react";
@@ -46,7 +47,9 @@ export const Root = ({
       {...props}
     >
       <body className={bodyClassName}>
-        <ThemeProvider>{children}</ThemeProvider>
+        <ThemeProvider>
+          <MainContent>{children}</MainContent>
+        </ThemeProvider>
       </body>
       {googleAnalyticsId && <GoogleAnalytics gaId={googleAnalyticsId} />}
       {amplitudeApiKey && <Amplitude apiKey={amplitudeApiKey} />}