Parcourir la source

chore: move component library from tailwind to sass

Connor Prussin il y a 1 an
Parent
commit
0c030309a7
100 fichiers modifiés avec 2691 ajouts et 1153 suppressions
  1. 2 2
      apps/api-reference/package.json
  2. 1 9
      apps/insights/eslint.config.js
  3. 2 2
      apps/insights/package.json
  4. 0 6
      apps/insights/postcss.config.js
  5. 1 9
      apps/insights/prettier.config.js
  6. 0 2
      apps/insights/src/app/layout.ts
  7. 66 0
      apps/insights/src/components/CopyButton/index.module.scss
  8. 10 11
      apps/insights/src/components/CopyButton/index.tsx
  9. 2 4
      apps/insights/src/components/Error/index.tsx
  10. 6 0
      apps/insights/src/components/H1/index.module.scss
  11. 3 1
      apps/insights/src/components/H1/index.tsx
  12. 5 0
      apps/insights/src/components/Loading/index.module.scss
  13. 4 4
      apps/insights/src/components/Loading/index.tsx
  14. 0 9
      apps/insights/src/components/MaxWidth/index.tsx
  15. 2 4
      apps/insights/src/components/NotFound/index.tsx
  16. 5 0
      apps/insights/src/components/Overview/index.module.scss
  17. 3 3
      apps/insights/src/components/Overview/index.tsx
  18. 0 186
      apps/insights/src/components/Paginator/index.tsx
  19. 33 9
      apps/insights/src/components/PriceFeeds/columns.ts
  20. 8 0
      apps/insights/src/components/PriceFeeds/epoch-select.module.scss
  21. 7 7
      apps/insights/src/components/PriceFeeds/epoch-select.tsx
  22. 43 0
      apps/insights/src/components/PriceFeeds/index.module.scss
  23. 13 14
      apps/insights/src/components/PriceFeeds/index.tsx
  24. 13 0
      apps/insights/src/components/PriceFeeds/layout.module.scss
  25. 4 4
      apps/insights/src/components/PriceFeeds/layout.tsx
  26. 9 35
      apps/insights/src/components/PriceFeeds/loading.tsx
  27. 13 0
      apps/insights/src/components/PriceFeeds/prices.module.scss
  28. 8 28
      apps/insights/src/components/PriceFeeds/prices.tsx
  29. 52 96
      apps/insights/src/components/PriceFeeds/results.tsx
  30. 18 9
      apps/insights/src/components/Publishers/columns.ts
  31. 8 0
      apps/insights/src/components/Publishers/epoch-select.module.scss
  32. 7 7
      apps/insights/src/components/Publishers/epoch-select.tsx
  33. 50 0
      apps/insights/src/components/Publishers/index.module.scss
  34. 10 20
      apps/insights/src/components/Publishers/index.tsx
  35. 13 0
      apps/insights/src/components/Publishers/layout.module.scss
  36. 5 29
      apps/insights/src/components/Publishers/layout.tsx
  37. 2 18
      apps/insights/src/components/Publishers/loading.tsx
  38. 27 24
      apps/insights/src/components/Publishers/results.tsx
  39. 106 0
      apps/insights/src/components/Root/footer.module.scss
  40. 20 24
      apps/insights/src/components/Root/footer.tsx
  41. 49 0
      apps/insights/src/components/Root/header.module.scss
  42. 34 34
      apps/insights/src/components/Root/header.tsx
  43. 17 0
      apps/insights/src/components/Root/index.module.scss
  44. 5 11
      apps/insights/src/components/Root/index.tsx
  45. 5 7
      apps/insights/src/components/Root/mobile-menu.tsx
  46. 0 15
      apps/insights/src/components/Root/orb.svg
  47. 26 0
      apps/insights/src/components/Root/search-button.tsx
  48. 7 75
      apps/insights/src/components/Root/tabs.tsx
  49. 29 0
      apps/insights/src/components/Root/theme-switch.module.scss
  50. 106 19
      apps/insights/src/components/Root/theme-switch.tsx
  51. 0 3
      apps/insights/src/tailwind.css
  52. 0 11
      apps/insights/tailwind.config.ts
  53. 1 1
      apps/staking/package.json
  54. 1 1
      governance/pyth_staking_sdk/package.json
  55. 0 1
      lazer/sdk/js/eslint.config.js
  56. 2 2
      packages/app-logger/package.json
  57. 15 4
      packages/component-library/.storybook/main.ts
  58. 0 8
      packages/component-library/.storybook/postcss.config.cjs
  59. 14 31
      packages/component-library/.storybook/preview.tsx
  60. 19 0
      packages/component-library/.storybook/storybook.module.scss
  61. 0 3
      packages/component-library/.storybook/tailwind.css
  62. 2 8
      packages/component-library/eslint.config.js
  63. 8 9
      packages/component-library/package.json
  64. 1 10
      packages/component-library/prettier.config.js
  65. 4 0
      packages/component-library/scss.d.ts
  66. 31 0
      packages/component-library/src/AppTabs/index.module.scss
  67. 32 0
      packages/component-library/src/AppTabs/index.stories.tsx
  68. 50 0
      packages/component-library/src/AppTabs/index.tsx
  69. 0 61
      packages/component-library/src/Button/arg-types.ts
  70. 4 1
      packages/component-library/src/Button/button-link.stories.tsx
  71. 134 0
      packages/component-library/src/Button/index.module.scss
  72. 58 4
      packages/component-library/src/Button/index.stories.tsx
  73. 24 99
      packages/component-library/src/Button/index.tsx
  74. 10 0
      packages/component-library/src/Card/index.module.scss
  75. 27 0
      packages/component-library/src/Card/index.stories.tsx
  76. 5 32
      packages/component-library/src/Card/index.tsx
  77. 15 0
      packages/component-library/src/Html/base.scss
  78. 9 0
      packages/component-library/src/Html/index.tsx
  79. 28 0
      packages/component-library/src/Link/index.module.scss
  80. 2 7
      packages/component-library/src/Link/index.tsx
  81. 36 0
      packages/component-library/src/Paginator/index.module.scss
  82. 57 0
      packages/component-library/src/Paginator/index.stories.tsx
  83. 276 0
      packages/component-library/src/Paginator/index.tsx
  84. 150 0
      packages/component-library/src/SearchInput/index.module.scss
  85. 49 0
      packages/component-library/src/SearchInput/index.stories.tsx
  86. 43 0
      packages/component-library/src/SearchInput/index.tsx
  87. 83 0
      packages/component-library/src/Select/index.module.scss
  88. 95 0
      packages/component-library/src/Select/index.stories.tsx
  89. 70 62
      packages/component-library/src/Select/index.tsx
  90. 16 0
      packages/component-library/src/Skeleton/index.module.scss
  91. 29 0
      packages/component-library/src/Skeleton/index.stories.tsx
  92. 15 6
      packages/component-library/src/Skeleton/index.tsx
  93. 143 0
      packages/component-library/src/Table/index.module.scss
  94. 99 0
      packages/component-library/src/Table/index.stories.tsx
  95. 46 48
      packages/component-library/src/Table/index.tsx
  96. 32 0
      packages/component-library/src/TableCard/index.module.scss
  97. 51 0
      packages/component-library/src/TableCard/index.stories.tsx
  98. 32 0
      packages/component-library/src/TableCard/index.tsx
  99. 14 0
      packages/component-library/src/UnstyledTabs/index.tsx
  100. 0 4
      packages/component-library/src/index.ts

+ 2 - 2
apps/api-reference/package.json

@@ -13,8 +13,8 @@
     "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_gbljYVzp0m5EpCuOF6nZpM4WMFM6 vercel env pull",
     "start:dev": "next dev --port 3002",
     "start:prod": "next start --port 3002",
-    "test:format": "jest --selectProjects format",
-    "test:lint": "jest --selectProjects lint",
+    "test:format": "prettier --check .",
+    "test:lint": "eslint .",
     "test:types": "tsc"
   },
   "dependencies": {

+ 1 - 9
apps/insights/eslint.config.js

@@ -1,9 +1 @@
-import { fileURLToPath } from "node:url";
-
-import { nextjs, tailwind, storybook } from "@cprussin/eslint-config";
-
-const tailwindConfig = fileURLToPath(
-  import.meta.resolve(`./tailwind.config.ts`),
-);
-
-export default [...nextjs, ...tailwind(tailwindConfig), ...storybook];
+export { nextjs as default } from "@cprussin/eslint-config";

+ 2 - 2
apps/insights/package.json

@@ -14,7 +14,7 @@
     "start:dev": "next dev --port 3003",
     "start:prod": "next start --port 3003",
     "test:format": "prettier --check .",
-    "test:lint": "jest --selectProjects lint",
+    "test:lint": "eslint .",
     "test:types": "tsc"
   },
   "dependencies": {
@@ -55,7 +55,7 @@
     "jest": "catalog:",
     "postcss": "catalog:",
     "prettier": "catalog:",
-    "tailwindcss": "catalog:",
+    "sass": "catalog:",
     "typescript": "catalog:",
     "vercel": "catalog:"
   }

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

@@ -1,6 +0,0 @@
-export default {
-  plugins: {
-    autoprefixer: {},
-    tailwindcss: {},
-  },
-};

+ 1 - 9
apps/insights/prettier.config.js

@@ -1,9 +1 @@
-import { fileURLToPath } from "node:url";
-
-import { base, tailwind, mergeConfigs } from "@cprussin/prettier-config";
-
-const tailwindConfig = fileURLToPath(
-  import.meta.resolve(`./tailwind.config.ts`),
-);
-
-export default mergeConfigs([base, tailwind(tailwindConfig)]);
+export { base as default } from "@cprussin/prettier-config";

+ 0 - 2
apps/insights/src/app/layout.ts

@@ -1,4 +1,2 @@
-import "../tailwind.css";
-
 export { Root as default } from "../components/Root";
 export { metadata, viewport } from "../metadata";

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

@@ -0,0 +1,66 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.copyButton {
+  margin: -#{theme.spacing(0.5)} -0.5em;
+  display: inline-block;
+  white-space: nowrap;
+  border-radius: theme.border-radius("md");
+  padding: theme.spacing(0.5) 0.5em;
+  border: none;
+  background: none;
+  cursor: pointer;
+  transition: background-color 100ms linear;
+  outline: none;
+
+  .iconContainer {
+    position: relative;
+    top: 0.125em;
+    margin-left: theme.spacing(1);
+    display: inline-block;
+
+    .copyIconContainer {
+      opacity: 0.5;
+      transition: opacity 100ms linear;
+
+      .copyIcon {
+        width: 1em;
+        height: 1em;
+      }
+
+      .copyIconLabel {
+        @include theme.sr-only;
+      }
+    }
+
+    .checkIcon {
+      position: absolute;
+      inset: 0;
+      color: theme.color("states", "success", "normal");
+      opacity: 0;
+      transition: opacity 100ms linear;
+    }
+  }
+
+  &[data-hovered] {
+    background-color: theme.color("button", "outline", "background", "hover");
+  }
+
+  &[data-pressed] {
+    background-color: theme.color("button", "outline", "background", "active");
+  }
+
+  &[data-focus-visible] {
+    outline: 1px auto currentcolor;
+    outline-offset: theme.spacing(1);
+  }
+
+  &[data-is-copied] .iconContainer {
+    .copyIconContainer {
+      opacity: 0;
+    }
+
+    .checkIcon {
+      opacity: 1;
+    }
+  }
+}

+ 10 - 11
apps/insights/src/components/CopyButton/index.tsx

@@ -1,11 +1,14 @@
 "use client";
 
-import { Copy, Check } from "@phosphor-icons/react/dist/ssr";
+import { Check } from "@phosphor-icons/react/dist/ssr/Check";
+import { Copy } from "@phosphor-icons/react/dist/ssr/Copy";
 import { useLogger } from "@pythnetwork/app-logger";
 import { UnstyledButton } from "@pythnetwork/component-library/UnstyledButton";
 import clsx from "clsx";
 import { type ComponentProps, useCallback, useEffect, useState } from "react";
 
+import styles from "./index.module.scss";
+
 type CopyButtonProps = ComponentProps<typeof UnstyledButton> & {
   text: string;
 };
@@ -51,11 +54,7 @@ export const CopyButton = ({
   return (
     <UnstyledButton
       onPress={copy}
-      isDisabled={isCopied}
-      className={clsx(
-        "group/copy-button mx-[-0.5em] -mt-0.5 inline-block whitespace-nowrap rounded-md px-[0.5em] py-0.5 outline-none outline-0 outline-steel-600 transition data-[hovered]:bg-black/5 data-[focus-visible]:outline-2 dark:outline-steel-300 dark:data-[hovered]:bg-white/10",
-        className,
-      )}
+      className={clsx(styles.copyButton, className)}
       {...(isCopied && { "data-is-copied": true })}
       {...props}
     >
@@ -64,12 +63,12 @@ export const CopyButton = ({
           <span>
             {typeof children === "function" ? children(...args) : children}
           </span>
-          <span className="relative top-[0.125em] ml-1 inline-block">
-            <span className="opacity-50 transition-opacity duration-100 group-data-[is-copied]/copy-button:opacity-0">
-              <Copy className="size-[1em]" />
-              <div className="sr-only">Copy to clipboard</div>
+          <span className={styles.iconContainer}>
+            <span className={styles.copyIconContainer}>
+              <Copy className={styles.copyIcon} />
+              <div className={styles.copyIconLabel}>Copy to clipboard</div>
             </span>
-            <Check className="absolute inset-0 text-green-600 opacity-0 transition-opacity duration-100 group-data-[is-copied]/copy-button:opacity-100" />
+            <Check className={styles.checkIcon} />
           </span>
         </>
       )}

+ 2 - 4
apps/insights/src/components/Error/index.tsx

@@ -2,8 +2,6 @@ import { useLogger } from "@pythnetwork/app-logger";
 import { Button } from "@pythnetwork/component-library/Button";
 import { useEffect } from "react";
 
-import { MaxWidth } from "../MaxWidth";
-
 type Props = {
   error: Error & { digest?: string };
   reset?: () => void;
@@ -17,13 +15,13 @@ export const Error = ({ error, reset }: Props) => {
   }, [error, logger]);
 
   return (
-    <MaxWidth>
+    <div>
       <h1>Uh oh!</h1>
       <h2>Something went wrong</h2>
       <p>
         Error Details: <strong>{error.digest ?? error.message}</strong>
       </p>
       {reset && <Button onPress={reset}>Reset</Button>}
-    </MaxWidth>
+    </div>
   );
 };

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

@@ -0,0 +1,6 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.h1 {
+  font-size: theme.font-size("2xl");
+  font-weight: theme.font-weight("medium");
+}

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

@@ -1,8 +1,10 @@
 import clsx from "clsx";
 import type { ComponentProps } from "react";
 
+import styles from "./index.module.scss";
+
 export const H1 = ({ className, children, ...props }: ComponentProps<"h1">) => (
-  <h1 className={clsx(className, "text-2xl font-medium")} {...props}>
+  <h1 className={clsx(styles.h1, className)} {...props}>
     {children}
   </h1>
 );

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

@@ -0,0 +1,5 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.loading {
+  @include theme.max-width;
+}

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

@@ -1,12 +1,12 @@
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 
+import styles from "./index.module.scss";
 import { H1 } from "../H1";
-import { MaxWidth } from "../MaxWidth";
 
 export const Loading = () => (
-  <MaxWidth>
+  <div className={styles.loading}>
     <H1>
-      <Skeleton className="w-60" />
+      <Skeleton width={60} />
     </H1>
-  </MaxWidth>
+  </div>
 );

+ 0 - 9
apps/insights/src/components/MaxWidth/index.tsx

@@ -1,9 +0,0 @@
-import clsx from "clsx";
-import type { ComponentProps } from "react";
-
-export const MaxWidth = ({ className, ...props }: ComponentProps<"div">) => (
-  <div
-    className={clsx("mx-auto box-content max-w-screen-2xl px-6", className)}
-    {...props}
-  />
-);

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

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

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

@@ -0,0 +1,5 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.overview {
+  @include theme.max-width;
+}

+ 3 - 3
apps/insights/src/components/Overview/index.tsx

@@ -1,8 +1,8 @@
+import styles from "./index.module.scss";
 import { H1 } from "../H1";
-import { MaxWidth } from "../MaxWidth";
 
 export const Overview = () => (
-  <MaxWidth>
+  <div className={styles.overview}>
     <H1>Overview</H1>
-  </MaxWidth>
+  </div>
 );

+ 0 - 186
apps/insights/src/components/Paginator/index.tsx

@@ -1,186 +0,0 @@
-import {
-  CaretLeft,
-  CaretRight,
-  CircleNotch,
-} from "@phosphor-icons/react/dist/ssr";
-import { ButtonLink } from "@pythnetwork/component-library/Button";
-import { Select } from "@pythnetwork/component-library/Select";
-import { UnstyledToolbar } from "@pythnetwork/component-library/UnstyledToolbar";
-import {
-  type ComponentProps,
-  useTransition,
-  useMemo,
-  useCallback,
-} from "react";
-
-type Props = {
-  numPages: number;
-  currentPage: number;
-  setCurrentPage: (newPage: number) => void;
-  pageSize: number;
-  setPageSize: (newPageSize: number) => void;
-  mkPageLink: (page: number) => string;
-};
-
-export const Paginator = ({
-  numPages,
-  currentPage,
-  pageSize,
-  setCurrentPage,
-  setPageSize,
-  mkPageLink,
-}: Props) => (
-  <div className="flex flex-row justify-between p-4">
-    <PageSizeSelect pageSize={pageSize} setPageSize={setPageSize} />
-    {numPages > 1 && (
-      <PaginatorToolbar
-        currentPage={currentPage}
-        numPages={numPages}
-        setCurrentPage={setCurrentPage}
-        mkPageLink={mkPageLink}
-      />
-    )}
-  </div>
-);
-
-type PageSizeSelectProps = {
-  pageSize: number;
-  setPageSize: (newPageSize: number) => void;
-};
-
-const PageSizeSelect = ({ pageSize, setPageSize }: PageSizeSelectProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-
-  const onChange = useCallback(
-    (newPageSize: number) => {
-      startTransition(() => {
-        setPageSize(newPageSize);
-      });
-    },
-    [startTransition, setPageSize],
-  );
-
-  return (
-    <div className="flex flex-row items-center gap-1">
-      <Select
-        label="Page size"
-        hideLabel
-        options={[10, 20, 50, 100] as const}
-        selectedKey={pageSize}
-        onSelectionChange={onChange}
-        show={(value) => `${value.toString()} per page`}
-        variant="ghost"
-        size="xs"
-      />
-      {isTransitioning && <CircleNotch className="size-4 animate-spin" />}
-    </div>
-  );
-};
-
-type PaginatorProps = {
-  numPages: number;
-  currentPage: number;
-  setCurrentPage: (newPage: number) => void;
-  mkPageLink: (page: number) => string;
-};
-
-const PaginatorToolbar = ({
-  numPages,
-  currentPage,
-  setCurrentPage,
-  mkPageLink,
-}: PaginatorProps) => {
-  const first =
-    currentPage <= 3 || numPages <= 5
-      ? 1
-      : currentPage - 2 - Math.max(2 - (numPages - currentPage), 0);
-  const pages = Array.from({ length: Math.min(numPages - first + 1, 5) })
-    .fill(undefined)
-    .map((_, i) => i + first);
-
-  return (
-    <UnstyledToolbar aria-label="Page" className="flex flex-row gap-1">
-      <PageLink
-        hideText
-        beforeIcon={CaretLeft}
-        isDisabled={currentPage === 1}
-        page={1}
-        setCurrentPage={setCurrentPage}
-        mkPageLink={mkPageLink}
-      >
-        First Page
-      </PageLink>
-      {pages.map((page) => {
-        return page === currentPage ? (
-          <SelectedPage key={page}>{page.toString()}</SelectedPage>
-        ) : (
-          <PageLink
-            key={page}
-            page={page}
-            aria-label={`Page ${page.toString()}`}
-            setCurrentPage={setCurrentPage}
-            mkPageLink={mkPageLink}
-          >
-            {page.toString()}
-          </PageLink>
-        );
-      })}
-      <PageLink
-        hideText
-        beforeIcon={CaretRight}
-        isDisabled={currentPage === numPages}
-        page={numPages}
-        setCurrentPage={setCurrentPage}
-        mkPageLink={mkPageLink}
-      >
-        Last Page
-      </PageLink>
-    </UnstyledToolbar>
-  );
-};
-
-type PageLinkProps = Omit<
-  ComponentProps<typeof ButtonLink>,
-  "variant" | "size" | "href" | "onPress"
-> & {
-  page: number;
-  setCurrentPage: (newPage: number) => void;
-  mkPageLink: (page: number) => string;
-};
-
-const PageLink = ({
-  page,
-  isDisabled,
-  setCurrentPage,
-  mkPageLink,
-  ...props
-}: PageLinkProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-
-  const url = useMemo(() => mkPageLink(page), [page, mkPageLink]);
-  const onPress = useCallback(() => {
-    startTransition(() => {
-      setCurrentPage(page);
-    });
-  }, [setCurrentPage, page]);
-
-  return (
-    <ButtonLink
-      variant="ghost"
-      size="xs"
-      onPress={onPress}
-      href={url}
-      isDisabled={isDisabled === true || isTransitioning}
-      {...props}
-    />
-  );
-};
-
-const SelectedPage = ({ children }: { children: string }) => (
-  <div
-    className="inline-block h-6 rounded-md bg-black/10 px-button-padding-xs text-[0.6875rem] font-medium leading-6 text-stone-900 dark:bg-white/10 dark:text-steel-50"
-    key={children}
-  >
-    <span className="px-1">{children}</span>
-  </div>
-);

+ 33 - 9
apps/insights/src/components/PriceFeeds/columns.ts

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

+ 8 - 0
apps/insights/src/components/PriceFeeds/epoch-select.module.scss

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

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

@@ -1,22 +1,22 @@
 "use client";
 
-import {
-  CaretLeft,
-  CaretRight,
-  CalendarDots,
-} from "@phosphor-icons/react/dist/ssr";
+import { CalendarDots } from "@phosphor-icons/react/dist/ssr/CalendarDots";
+import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
+import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Select } from "@pythnetwork/component-library/Select";
 
+import styles from "./epoch-select.module.scss";
+
 export const EpochSelect = () => (
-  <div className="flex flex-row items-center gap-2">
+  <div className={styles.epochSelect}>
     <Button variant="outline" size="sm" beforeIcon={CaretLeft} hideText>
       Previous Epoch
     </Button>
     <Select
       variant="outline"
       size="sm"
-      beforeIcon={CalendarDots}
+      icon={CalendarDots}
       options={["27 Oct – 3 Nov"]}
       selectedKey="27 Oct – 3 Nov"
       label="Epoch"

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

@@ -0,0 +1,43 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.assetName {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(3);
+
+  .icon {
+    width: theme.spacing(6);
+    height: theme.spacing(6);
+  }
+
+  .name {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    gap: theme.spacing(1);
+
+    .firstPart {
+      font-weight: theme.font-weight("medium");
+    }
+
+    .divider {
+      font-weight: theme.font-weight("light");
+      color: theme.color("heading");
+    }
+
+    .part {
+      opacity: 0.6;
+    }
+  }
+}
+
+.assetType {
+  display: inline-block;
+  border-radius: theme.border-radius("3xl");
+  border: 1px solid theme.color("states", "neutral", "normal");
+  padding: 0 theme.spacing(2);
+  font-size: theme.font-size("xxs");
+  text-transform: uppercase;
+  line-height: theme.spacing(4);
+  color: theme.color("states", "neutral", "normal");
+}

+ 13 - 14
apps/insights/src/components/PriceFeeds/index.tsx

@@ -2,7 +2,7 @@ import Generic from "cryptocurrency-icons/svg/color/generic.svg";
 import { Fragment } from "react";
 import { z } from "zod";
 
-import { columns } from "./columns";
+import styles from "./index.module.scss";
 import { Price } from "./prices";
 import { Results } from "./results";
 import { getIcon } from "../../icons";
@@ -13,8 +13,6 @@ export const PriceFeeds = async () => {
 
   return (
     <Results
-      label="Price Feeds"
-      columns={columns}
       priceFeeds={priceFeeds.map(({ symbol, product }) => ({
         symbol,
         key: product.price_account,
@@ -36,16 +34,19 @@ const AssetName = ({ children }: { children: string }) => {
   const [firstPart, ...parts] = children.split("/");
   const Icon = firstPart ? (getIcon(firstPart) ?? Generic) : Generic;
   return (
-    <div className="flex flex-row gap-3">
-      <Icon className="size-6" width="100%" height="100%" viewBox="0 0 32 32" />
-      <div className="flex flex-row items-center gap-1">
-        <span className="font-medium">{firstPart}</span>
+    <div className={styles.assetName}>
+      <Icon
+        className={styles.icon}
+        width="100%"
+        height="100%"
+        viewBox="0 0 32 32"
+      />
+      <div className={styles.name}>
+        <span className={styles.firstPart}>{firstPart}</span>
         {parts.map((part, i) => (
           <Fragment key={i}>
-            <span className="font-light text-stone-600 dark:text-steel-400">
-              /
-            </span>
-            <span className="opacity-60">{part}</span>
+            <span className={styles.divider}>/</span>
+            <span className={styles.part}>{part}</span>
           </Fragment>
         ))}
       </div>
@@ -54,9 +55,7 @@ const AssetName = ({ children }: { children: string }) => {
 };
 
 const AssetType = ({ children }: { children: string }) => (
-  <span className="inline-block rounded-3xl border border-steel-900 px-2 text-[0.625rem] uppercase leading-4 text-steel-900 dark:border-steel-50 dark:text-steel-50">
-    {children}
-  </span>
+  <span className={styles.assetType}>{children}</span>
 );
 
 const getPriceFeeds = async () => {

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

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

+ 4 - 4
apps/insights/src/components/PriceFeeds/layout.tsx

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

+ 9 - 35
apps/insights/src/components/PriceFeeds/loading.tsx

@@ -1,40 +1,14 @@
-import { ChartLine } from "@phosphor-icons/react/dist/ssr";
-import { Card } from "@pythnetwork/component-library/Card";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { Table } from "@pythnetwork/component-library/Table";
+import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
+import { TableCard } from "@pythnetwork/component-library/TableCard";
 
 import { columns } from "./columns";
 
 export const PriceFeedsLoading = () => (
-  <Card
-    header={
-      <div className="flex flex-row items-center gap-3">
-        <ChartLine className="size-6 text-violet-600" />
-        <div>Price Feeds</div>
-      </div>
-    }
-    full
-  >
-    <Table
-      label="Publishers"
-      columns={columns}
-      rows={[
-        {
-          id: 1,
-          data: {
-            asset: (
-              <div className="mr-6">
-                <Skeleton className="w-28" />
-              </div>
-            ),
-            assetType: <Skeleton className="w-20" />,
-            price: <Skeleton className="w-20" />,
-            uptime: <Skeleton className="w-6" />,
-            deviation: <Skeleton className="w-6" />,
-            staleness: <Skeleton className="w-6" />,
-          },
-        },
-      ]}
-    />
-  </Card>
+  <TableCard
+    label="Price Feeds"
+    icon={ChartLine}
+    columns={columns}
+    isLoading={true}
+    rows={[]}
+  />
 );

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

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

+ 8 - 28
apps/insights/src/components/PriceFeeds/prices.tsx

@@ -4,7 +4,6 @@ import { useLogger } from "@pythnetwork/app-logger";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { useMap } from "@react-hookz/web";
 import { PublicKey } from "@solana/web3.js";
-import clsx from "clsx";
 import {
   type ComponentProps,
   createContext,
@@ -13,17 +12,14 @@ import {
 } from "react";
 import { useNumberFormatter } from "react-aria";
 
+import styles from "./prices.module.scss";
 import { client, subscribe } from "../../pyth";
 
 const PriceContext = createContext<
   Map<string, [number, ChangeDirection]> | undefined
 >(undefined);
 
-enum ChangeDirection {
-  Up,
-  Down,
-  Flat,
-}
+type ChangeDirection = "up" | "down" | "flat";
 
 type PriceProviderProps = Omit<ComponentProps<typeof PriceContext>, "value"> & {
   feedKeys: string[];
@@ -40,11 +36,9 @@ export const Price = ({ account }: { account: string }) => {
   const price = usePrices().get(account);
 
   return price === undefined ? (
-    <Skeleton className="w-20" />
+    <Skeleton width={20} />
   ) : (
-    <span
-      className={clsx("transition-colors duration-100", getColor(price[1]))}
-    >
+    <span className={styles.price} data-direction={price[1]}>
       {numberFormatter.format(price[0])}
     </span>
   );
@@ -65,7 +59,7 @@ const usePriceData = (feedKeys: string[]) => {
           for (const [i, price] of initialPrices.entries()) {
             const key = initialFeedKeys[i];
             if (key && !priceData.has(key)) {
-              priceData.set(key, [price.aggregate.price, ChangeDirection.Flat]);
+              priceData.set(key, [price.aggregate.price, "flat"]);
             }
           }
         })
@@ -120,24 +114,10 @@ const getChangeDirection = (
   price: number,
 ): ChangeDirection => {
   if (prevPrice === undefined || prevPrice === price) {
-    return ChangeDirection.Flat;
+    return "flat";
   } else if (prevPrice < price) {
-    return ChangeDirection.Up;
+    return "up";
   } else {
-    return ChangeDirection.Down;
-  }
-};
-
-const getColor = (direction: ChangeDirection) => {
-  switch (direction) {
-    case ChangeDirection.Down: {
-      return "text-red-500";
-    }
-    case ChangeDirection.Up: {
-      return "text-green-500";
-    }
-    case ChangeDirection.Flat: {
-      return;
-    }
+    return "down";
   }
 };

+ 52 - 96
apps/insights/src/components/PriceFeeds/results.tsx

@@ -1,14 +1,10 @@
 "use client";
 
-import {
-  MagnifyingGlass,
-  ChartLine,
-  CircleNotch,
-} from "@phosphor-icons/react/dist/ssr";
+import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
 import { useLogger } from "@pythnetwork/app-logger";
-import { Card } from "@pythnetwork/component-library/Card";
-import { Table } from "@pythnetwork/component-library/Table";
-import clsx from "clsx";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { TableCard } from "@pythnetwork/component-library/TableCard";
 import { usePathname } from "next/navigation";
 import {
   parseAsString,
@@ -23,20 +19,18 @@ import {
   useCallback,
 } from "react";
 import { useFilter, useCollator } from "react-aria";
-import { Input, SearchField } from "react-aria-components";
 
+import { columns } from "./columns";
 import { PriceProvider } from "./prices";
-import { Paginator } from "../Paginator";
 
-type Props<T extends string> = Omit<
-  ComponentProps<typeof Table<T>>,
-  "isLoading" | "rows"
-> & {
+type Props = {
   priceFeeds: {
     symbol: string;
     key: string;
     displaySymbol: string;
-    data: ComponentProps<typeof Table<T>>["rows"][number]["data"];
+    data: ComponentProps<
+      typeof TableCard<(typeof columns)[number]["id"]>
+    >["rows"][number]["data"];
   }[];
 };
 
@@ -46,10 +40,7 @@ const params = {
   search: parseAsString.withDefault(""),
 };
 
-export const Results = <T extends string>({
-  priceFeeds,
-  ...props
-}: Props<T>) => {
+export const Results = ({ priceFeeds }: Props) => {
   const [isTransitioning, startTransition] = useTransition();
   const [{ page, pageSize, search }, setQuery] = useQueryStates(params);
   const filter = useFilter({ sensitivity: "base", usage: "search" });
@@ -61,13 +52,20 @@ export const Results = <T extends string>({
         : priceFeeds.filter((feed) => filter.contains(feed.symbol, search)),
     [search, priceFeeds, filter],
   );
-  const rows = useMemo(
+  const sortedRows = useMemo(
     () =>
-      filteredFeeds
-        .sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol))
-        .slice((page - 1) * pageSize, page * pageSize)
-        .map(({ key, data }) => ({ id: key, href: "/", data })),
-    [page, pageSize, filteredFeeds, collator],
+      filteredFeeds.sort((a, b) =>
+        collator.compare(a.displaySymbol, b.displaySymbol),
+      ),
+    [filteredFeeds, collator],
+  );
+  const paginatedRows = useMemo(
+    () => sortedRows.slice((page - 1) * pageSize, page * pageSize),
+    [page, pageSize, sortedRows],
+  );
+  const rows = useMemo(
+    () => paginatedRows.map(({ key, data }) => ({ id: key, href: "/", data })),
+    [paginatedRows],
   );
   const numPages = useMemo(
     () => Math.ceil(filteredFeeds.length / pageSize),
@@ -116,82 +114,40 @@ export const Results = <T extends string>({
   const mkPageLink = useCallback(
     (page: number) => {
       const serialize = createSerializer(params);
-      return `${pathname}${serialize({ page })}`;
-    },
-    [pathname],
-  );
-
-  return (
-    <Card
-      header={
-        <div className="flex flex-row items-center gap-3">
-          <ChartLine className="size-6 text-violet-600" />
-          <div>Price Feeds</div>
-        </div>
-      }
-      toolbarLabel="Price Feeds"
-      toolbar={<SearchBar search={search} setSearch={updateSearch} />}
-      full
-    >
-      <PriceProvider feedKeys={feedKeys}>
-        <Table
-          isLoading={isTransitioning}
-          rows={rows}
-          renderEmptyState={() => <p>No results!</p>}
-          {...props}
-        />
-      </PriceProvider>
-      <Paginator
-        numPages={numPages}
-        currentPage={page}
-        setCurrentPage={updatePage}
-        pageSize={pageSize}
-        setPageSize={updatePageSize}
-        mkPageLink={mkPageLink}
-      />
-    </Card>
-  );
-};
-
-type SearchBarProps = {
-  search: string;
-  setSearch: (newSearch: string) => void;
-};
-
-const SearchBar = ({ search, setSearch }: SearchBarProps) => {
-  const [isTransitioning, startTransition] = useTransition();
-  const Icon = isTransitioning ? CircleNotch : MagnifyingGlass;
-
-  const doSearch = useCallback(
-    (search: string) => {
-      startTransition(() => {
-        setSearch(search);
-      });
+      return `${pathname}${serialize({ page, pageSize })}`;
     },
-    [setSearch, startTransition],
+    [pathname, pageSize],
   );
 
   return (
-    <div className="space-x-2">
-      <SearchField
-        defaultValue={search}
-        onChange={doSearch}
-        aria-label="Search"
-        className="inline-block"
-      >
-        <span className="relative inline-block h-9 w-48">
-          <Input
-            className="inline-block size-full rounded-lg border border-stone-300 bg-white px-button-padding-sm pl-9 text-sm ring-violet-500 placeholder:text-stone-400 data-[focused]:ring-2 data-[focused]:ring-violet-500 focus:border-stone-300 focus:outline-0 dark:bg-steel-900 dark:placeholder:text-steel-400"
-            placeholder="Search"
+    <PriceProvider feedKeys={feedKeys}>
+      <TableCard
+        label="Price Feeds"
+        icon={ChartLine}
+        columns={columns}
+        isUpdating={isTransitioning}
+        rows={rows}
+        renderEmptyState={() => <p>No results!</p>}
+        toolbar={
+          <SearchInput
+            defaultValue={search}
+            onChange={updateSearch}
+            size="sm"
+            width={40}
           />
-          <Icon
-            className={clsx(
-              "pointer-events-none absolute inset-y-2 left-button-padding-sm size-5",
-              { "animate-spin": isTransitioning },
-            )}
+        }
+        footer={
+          <Paginator
+            numPages={numPages}
+            currentPage={page}
+            onPageChange={updatePage}
+            pageSize={pageSize}
+            onPageSizeChange={updatePageSize}
+            pageSizeOptions={[10, 20, 30, 40, 50]}
+            mkPageLink={mkPageLink}
           />
-        </span>
-      </SearchField>
-    </div>
+        }
+      />
+    </PriceProvider>
   );
 };

+ 18 - 9
apps/insights/src/components/Publishers/columns.ts

@@ -1,21 +1,30 @@
+import type { ColumnConfig } from "@pythnetwork/component-library/Table";
+
 export const columns = [
-  { id: "rank" as const, name: "RANKING" },
+  { id: "rank", name: "RANKING", loadingSkeletonWidth: 10 },
   {
-    id: "name" as const,
+    id: "name",
     name: "NAME / ID",
     isRowHeader: true,
     fill: true,
-    alignment: "left" as const,
+    alignment: "left",
+    loadingSkeletonWidth: 48,
   },
   {
-    id: "activeFeeds" as const,
+    id: "activeFeeds",
     name: "ACTIVE FEEDS",
-    alignment: "left" as const,
+    alignment: "center",
+    loadingSkeletonWidth: 6,
   },
   {
-    id: "inactiveFeeds" as const,
+    id: "inactiveFeeds",
     name: "INACTIVE FEEDS",
-    alignment: "left" as const,
+    alignment: "center",
+    loadingSkeletonWidth: 6,
   },
-  { id: "score" as const, name: "SCORE" },
-];
+  {
+    id: "score",
+    name: "SCORE",
+    loadingSkeletonWidth: 6,
+  },
+] satisfies ColumnConfig<string>[];

+ 8 - 0
apps/insights/src/components/Publishers/epoch-select.module.scss

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

+ 7 - 7
apps/insights/src/components/Publishers/epoch-select.tsx

@@ -1,22 +1,22 @@
 "use client";
 
-import {
-  CaretLeft,
-  CaretRight,
-  CalendarDots,
-} from "@phosphor-icons/react/dist/ssr";
+import { CalendarDots } from "@phosphor-icons/react/dist/ssr/CalendarDots";
+import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
+import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Select } from "@pythnetwork/component-library/Select";
 
+import styles from "./epoch-select.module.scss";
+
 export const EpochSelect = () => (
-  <div className="flex flex-row items-center gap-2">
+  <div className={styles.epochSelect}>
     <Button variant="outline" size="sm" beforeIcon={CaretLeft} hideText>
       Previous Epoch
     </Button>
     <Select
       variant="outline"
       size="sm"
-      beforeIcon={CalendarDots}
+      icon={CalendarDots}
       options={["27 Oct – 3 Nov"]}
       selectedKey="27 Oct – 3 Nov"
       label="Epoch"

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

@@ -0,0 +1,50 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.publisherNameContainer {
+  display: flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  gap: theme.spacing(4);
+
+  .publisherIcon {
+    display: flex;
+    flex: none;
+    width: theme.spacing(9);
+    height: theme.spacing(9);
+  }
+
+  .nameAndKey {
+    display: flex;
+    flex-direction: column;
+    gap: theme.spacing(1);
+
+    .publisherName {
+      font-size: theme.font-size("sm");
+      font-weight: theme.font-weight("medium");
+      line-height: theme.spacing(4);
+    }
+  }
+}
+
+.publisherKey {
+  font-size: theme.font-size("xs");
+}
+
+.ranking {
+  display: inline-block;
+  height: theme.spacing(6);
+  width: 100%;
+  border-radius: theme.border-radius("md");
+  text-align: center;
+  font-size: theme.font-size("sm");
+  font-weight: theme.font-weight("medium");
+  line-height: theme.spacing(6);
+  color: light-dark(
+    theme.pallette-color("steel", 800),
+    theme.pallette-color("steel", 300)
+  );
+  background: light-dark(
+    theme.pallette-color("steel", 200),
+    theme.pallette-color("steel", 700)
+  );
+}

+ 10 - 20
apps/insights/src/components/Publishers/index.tsx

@@ -3,7 +3,7 @@ import clsx from "clsx";
 import { type ComponentProps, createElement } from "react";
 import { z } from "zod";
 
-import { columns } from "./columns";
+import styles from "./index.module.scss";
 import { Results } from "./results";
 import { client as clickhouseClient } from "../../clickhouse";
 import { client as pythClient } from "../../pyth";
@@ -17,18 +17,14 @@ export const Publishers = async () => {
 
   return (
     <Results
-      label="Publishers"
-      columns={columns}
       publishers={publishers.map(({ key, rank, numSymbols }) => ({
         key,
         rank,
         data: {
           name: <PublisherName>{key}</PublisherName>,
           rank: <Ranking>{rank}</Ranking>,
-          activeFeeds: <span className="text-sm">{numSymbols}</span>,
-          inactiveFeeds: (
-            <span className="text-sm">{feedCount - numSymbols}</span>
-          ),
+          activeFeeds: numSymbols,
+          inactiveFeeds: feedCount - numSymbols,
           score: 0,
         },
       }))}
@@ -39,32 +35,26 @@ export const Publishers = async () => {
 const PublisherName = ({ children }: { children: string }) => {
   const knownPublisher = lookupPublisher(children);
   return knownPublisher ? (
-    <div className="flex flex-row items-center gap-4">
+    <div className={styles.publisherNameContainer}>
       {createElement(knownPublisher.icon.color, {
-        className: "flex-none size-9",
+        className: styles.publisherIcon,
       })}
-      <div className="space-y-1">
-        <div className="text-sm font-medium">{knownPublisher.name}</div>
-        <CopyButton className="text-xs" text={children}>
+      <div className={styles.nameAndKey}>
+        <div className={styles.publisherName}>{knownPublisher.name}</div>
+        <CopyButton className={styles.publisherKey ?? ""} text={children}>
           {children}
         </CopyButton>
       </div>
     </div>
   ) : (
-    <CopyButton className="text-xs" text={children}>
+    <CopyButton className={styles.publisherKey ?? ""} text={children}>
       {children}
     </CopyButton>
   );
 };
 
 const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
-  <span
-    className={clsx(
-      "inline-block h-6 w-full rounded-md bg-steel-200 text-center text-sm font-medium leading-6 text-steel-800 dark:bg-steel-700 dark:text-steel-300",
-      className,
-    )}
-    {...props}
-  />
+  <span className={clsx(styles.ranking, className)} {...props} />
 );
 
 const getPublishers = async () => {

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

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

+ 5 - 29
apps/insights/src/components/Publishers/layout.tsx

@@ -1,43 +1,19 @@
-import { Info } from "@phosphor-icons/react/dist/ssr";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Card } from "@pythnetwork/component-library/Card";
 import type { ReactNode } from "react";
 
 import { EpochSelect } from "./epoch-select";
+import styles from "./layout.module.scss";
 import { H1 } from "../H1";
-import { MaxWidth } from "../MaxWidth";
 
 type Props = {
   children: ReactNode | undefined;
 };
 
 export const PublishersLayout = ({ children }: Props) => (
-  <MaxWidth>
-    <div className="mb-12 flex flex-row items-center justify-between">
+  <div className={styles.publishersLayout}>
+    <div className={styles.header}>
       <H1>Publishers</H1>
       <EpochSelect />
     </div>
-    <Card
-      header="Publishers"
-      toolbarLabel="Publishers"
-      full
-      toolbar={
-        <>
-          <Button size="xs" variant="outline">
-            Show rankings
-          </Button>
-          <Button
-            size="xs"
-            variant="ghost"
-            beforeIcon={(props) => <Info weight="fill" {...props} />}
-            hideText
-          >
-            Help
-          </Button>
-        </>
-      }
-    >
-      {children}
-    </Card>
-  </MaxWidth>
+    {children}
+  </div>
 );

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

@@ -1,23 +1,7 @@
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { Table } from "@pythnetwork/component-library/Table";
+import { TableCard } from "@pythnetwork/component-library/TableCard";
 
 import { columns } from "./columns";
 
 export const PublishersLoading = () => (
-  <Table
-    label="Publishers"
-    columns={columns}
-    rows={[
-      {
-        id: 1,
-        data: {
-          activeFeeds: <Skeleton className="w-6" />,
-          inactiveFeeds: <Skeleton className="w-6" />,
-          name: <Skeleton className="w-48" />,
-          rank: <Skeleton className="w-10" />,
-          score: <Skeleton className="w-6" />,
-        },
-      },
-    ]}
-  />
+  <TableCard label="Publishers" columns={columns} isLoading rows={[]} />
 );

+ 27 - 24
apps/insights/src/components/Publishers/results.tsx

@@ -1,7 +1,8 @@
 "use client";
 
 import { useLogger } from "@pythnetwork/app-logger";
-import { Table } from "@pythnetwork/component-library/Table";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { TableCard } from "@pythnetwork/component-library/TableCard";
 import { usePathname } from "next/navigation";
 import { parseAsInteger, useQueryStates, createSerializer } from "nuqs";
 import {
@@ -11,16 +12,15 @@ import {
   useCallback,
 } from "react";
 
-import { Paginator } from "../Paginator";
+import { columns } from "./columns";
 
-type Props<T extends string> = Omit<
-  ComponentProps<typeof Table<T>>,
-  "isLoading" | "rows"
-> & {
+type Props = {
   publishers: {
     key: string;
     rank: number;
-    data: ComponentProps<typeof Table<T>>["rows"][number]["data"];
+    data: ComponentProps<
+      typeof TableCard<(typeof columns)[number]["id"]>
+    >["rows"][number]["data"];
   }[];
 };
 
@@ -29,10 +29,7 @@ const params = {
   pageSize: parseAsInteger.withDefault(20),
 };
 
-export const Results = <T extends string>({
-  publishers,
-  ...props
-}: Props<T>) => {
+export const Results = ({ publishers }: Props) => {
   const [isTransitioning, startTransition] = useTransition();
   const [{ page, pageSize }, setQuery] = useQueryStates(params);
   const rows = useMemo(
@@ -81,22 +78,28 @@ export const Results = <T extends string>({
   const mkPageLink = useCallback(
     (page: number) => {
       const serialize = createSerializer(params);
-      return `${pathname}${serialize({ page })}`;
+      return `${pathname}${serialize({ page, pageSize })}`;
     },
-    [pathname],
+    [pathname, pageSize],
   );
 
   return (
-    <>
-      <Table isLoading={isTransitioning} rows={rows} {...props} />
-      <Paginator
-        numPages={numPages}
-        currentPage={page}
-        setCurrentPage={updatePage}
-        pageSize={pageSize}
-        setPageSize={updatePageSize}
-        mkPageLink={mkPageLink}
-      />
-    </>
+    <TableCard
+      label="Publishers"
+      columns={columns}
+      isUpdating={isTransitioning}
+      rows={rows}
+      footer={
+        <Paginator
+          numPages={numPages}
+          currentPage={page}
+          onPageChange={updatePage}
+          pageSize={pageSize}
+          onPageSizeChange={updatePageSize}
+          mkPageLink={mkPageLink}
+          pageSizeOptions={[10, 20, 30, 40, 50]}
+        />
+      }
+    />
   );
 };

+ 106 - 0
apps/insights/src/components/Root/footer.module.scss

@@ -0,0 +1,106 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.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;
+    justify-content: space-between;
+
+    @include theme.max-width;
+
+    // XL
+    margin-bottom: theme.spacing(12);
+
+    // py-6
+
+    // flex-col
+
+    .left {
+      display: flex;
+      align-items: stretch;
+      justify-content: space-between;
+
+      // SM
+      gap: theme.spacing(6);
+      // gap-8
+
+      .logoLink {
+        margin: -#{theme.spacing(2)};
+        border-radius: theme.border-radius();
+        padding: theme.spacing(2);
+
+        .logo {
+          height: theme.spacing(5);
+        }
+
+        .logoLabel {
+          @include theme.sr-only;
+        }
+      }
+
+      .divider {
+        background-color: theme.color("border");
+        width: 1px;
+
+        // hidden sm:block
+      }
+
+      .help {
+        display: flex;
+        flex-flow: row nowrap;
+        align-items: center;
+        gap: theme.spacing(6);
+        font-size: theme.font-size("sm");
+      }
+    }
+
+    .right {
+      margin: 0 -#{theme.button-padding("sm", false)};
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+
+      // SM
+      justify-content: flex-end;
+      gap: theme.spacing(2);
+      // justify-between
+    }
+  }
+
+  .bottomContent {
+    display: flex;
+    gap: theme.spacing(6);
+
+    // SM
+    flex-flow: row nowrap;
+    justify-content: space-between;
+
+    // "flex-col
+
+    @include theme.max-width;
+
+    .copyright {
+      font-size: theme.font-size("xs");
+      color: theme.color("muted");
+    }
+
+    .legal {
+      display: flex;
+      flex-flow: row nowrap;
+      font-size: theme.font-size("xs");
+      gap: theme.spacing(6);
+    }
+  }
+}

+ 20 - 24
apps/insights/src/components/Root/footer.tsx

@@ -1,34 +1,32 @@
-import {
-  TelegramLogo,
-  GithubLogo,
-  XLogo,
-  DiscordLogo,
-  YoutubeLogo,
-} from "@phosphor-icons/react/dist/ssr";
+import { DiscordLogo } from "@phosphor-icons/react/dist/ssr/DiscordLogo";
+import { GithubLogo } from "@phosphor-icons/react/dist/ssr/GithubLogo";
+import { TelegramLogo } from "@phosphor-icons/react/dist/ssr/TelegramLogo";
+import { XLogo } from "@phosphor-icons/react/dist/ssr/XLogo";
+import { YoutubeLogo } from "@phosphor-icons/react/dist/ssr/YoutubeLogo";
 import { ButtonLink } from "@pythnetwork/component-library/Button";
 import { Link } from "@pythnetwork/component-library/Link";
 import type { ComponentProps } from "react";
 
+import styles from "./footer.module.scss";
 import Wordmark from "./wordmark.svg";
-import { MaxWidth } from "../MaxWidth";
 
 export const Footer = () => (
-  <footer className="z-10 space-y-6 bg-beige-100 py-6 sm:border-t sm:border-stone-300 sm:bg-white xl:space-y-12 xl:py-8 dark:bg-steel-900 dark:sm:border-steel-600 sm:dark:bg-steel-950">
-    <MaxWidth className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
-      <div className="flex items-stretch justify-between gap-8 sm:gap-6">
-        <Link href="https://www.pyth.network" className="-m-2 rounded p-2">
-          <Wordmark className="h-5" />
-          <div className="sr-only">Pyth Homepage</div>
+  <footer className={styles.footer}>
+    <div className={styles.topContent}>
+      <div className={styles.left}>
+        <Link href="https://www.pyth.network" className={styles.logoLink ?? ""}>
+          <Wordmark className={styles.logo} />
+          <div className={styles.logoLabel}>Pyth Homepage</div>
         </Link>
-        <div className="hidden w-px bg-stone-300 sm:block dark:bg-steel-600" />
-        <div className="space-x-6 text-sm">
+        <div className={styles.divider} />
+        <div className={styles.help}>
           <Link href="/">Help</Link>
           <Link href="https://docs.pyth.network" target="_blank">
             Documentation
           </Link>
         </div>
       </div>
-      <div className="-mx-button-padding-sm flex items-center justify-between sm:justify-end sm:gap-2">
+      <div className={styles.right}>
         <SocialLink href="https://t.me/Pyth_Network" icon={TelegramLogo}>
           Telegram
         </SocialLink>
@@ -51,12 +49,10 @@ export const Footer = () => (
           YouTube
         </SocialLink>
       </div>
-    </MaxWidth>
-    <MaxWidth className="flex flex-col gap-6 sm:flex-row sm:justify-between">
-      <small className="text-xs text-stone-600 dark:text-steel-400">
-        © 2024 Pyth Data Association
-      </small>
-      <div className="space-x-6 text-xs">
+    </div>
+    <div className={styles.bottomContent}>
+      <small className={styles.copyright}>© 2024 Pyth Data Association</small>
+      <div className={styles.legal}>
         <Link href="https://www.pyth.network/privacy-policy" target="_blank">
           Privacy Policy
         </Link>
@@ -64,7 +60,7 @@ export const Footer = () => (
           Terms of Use
         </Link>
       </div>
-    </MaxWidth>
+    </div>
   </footer>
 );
 

+ 49 - 0
apps/insights/src/components/Root/header.module.scss

@@ -0,0 +1,49 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.header {
+  position: sticky;
+  top: 0;
+  height: theme.spacing(20);
+  width: 100%;
+  background-color: theme.color("background", "primary");
+
+  .content {
+    height: 100%;
+    justify-content: space-between;
+    @include theme.row;
+    @include theme.max-width;
+
+    .leftMenu {
+      flex: none;
+      gap: theme.spacing(6);
+      @include theme.row;
+
+      .logo {
+        margin-top: 0.56456rem;
+        height: 2.81456rem;
+        width: theme.spacing(9);
+      }
+
+      .logoLabel {
+        @include theme.sr-only;
+      }
+
+      .appName {
+        font-size: theme.font-size("xl");
+        font-weight: theme.font-weight("semibold");
+        color: theme.color("heading");
+      }
+    }
+
+    .rightMenu {
+      flex: none;
+      gap: theme.spacing(2);
+      @include theme.row;
+      margin-right: -#{theme.button-padding("sm", false)};
+
+      .themeSwitch {
+        margin-left: theme.spacing(1);
+      }
+    }
+  }
+}

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

@@ -1,51 +1,51 @@
-import { MagnifyingGlass, Lifebuoy } from "@phosphor-icons/react/dist/ssr";
+import { Lifebuoy } from "@phosphor-icons/react/dist/ssr/Lifebuoy";
+import { AppTabs } from "@pythnetwork/component-library/AppTabs";
 import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
 import { Link } from "@pythnetwork/component-library/Link";
+import clsx from "clsx";
+import type { ComponentProps } from "react";
 
+import styles from "./header.module.scss";
 import Logo from "./logo.svg";
-import Orb from "./orb.svg";
-import { TabList } from "./tabs";
+import { SearchButton } from "./search-button";
 import { ThemeSwitch } from "./theme-switch";
-import { MaxWidth } from "../MaxWidth";
 
-export const Header = () => (
-  <header className="sticky top-0 z-10 h-20 w-full bg-white dark:bg-steel-950">
-    <MaxWidth className="flex h-full flex-row items-center justify-between">
-      <div className="flex flex-none flex-row items-center gap-6">
+export const Header = ({ className, ...props }: ComponentProps<"header">) => (
+  <header className={clsx(styles.header, className)} {...props}>
+    <div className={styles.content}>
+      <div className={styles.leftMenu}>
         <Link href="https://www.pyth.network">
-          <Logo className="mt-[0.56456rem] h-[2.81456rem] w-9" />
-          <div className="sr-only">Pyth Homepage</div>
+          <Logo className={styles.logo} />
+          <div className={styles.logoLabel}>Pyth Homepage</div>
         </Link>
-        <div className="inline-block h-9 whitespace-nowrap rounded-full bg-beige-100 pr-6 leading-9 dark:bg-steel-900">
-          <div className="relative inline-block size-9 align-top">
-            <Orb className="h-11 w-9" />
-          </div>
-          <span className="mx-3 text-sm font-medium">Insights</span>
-        </div>
-        <TabList />
+        <div className={styles.appName}>Insights</div>
+        <AppTabs
+          tabs={[
+            { href: "/", id: "/", children: "Overview" },
+            { href: "/publishers", id: "/publishers", children: "Publishers" },
+            {
+              href: "/price-feeds",
+              id: "/price-feeds",
+              children: "Price Feeds",
+            },
+          ]}
+        />
       </div>
-      <div className="flex flex-none flex-row items-center gap-2 lg:-mx-button-padding-sm">
-        <Button
-          beforeIcon={Lifebuoy}
-          variant="ghost"
-          size="sm"
-          className="hidden lg:inline-block"
-        >
+      <div className={styles.rightMenu}>
+        <Button beforeIcon={Lifebuoy} variant="ghost" size="sm" rounded>
           Support
         </Button>
-        <Button
-          beforeIcon={MagnifyingGlass}
-          variant="outline"
+        <SearchButton />
+        <ButtonLink
+          href="https://docs.pyth.network"
           size="sm"
-          className="hidden lg:inline-block"
+          rounded
+          target="_blank"
         >
-          ⌘ K
-        </Button>
-        <ButtonLink href="https://www.pyth.network" size="sm" target="_blank">
-          Integrate
+          Dev Docs
         </ButtonLink>
-        <ThemeSwitch className="ml-1 hidden lg:inline-block" />
+        <ThemeSwitch className={styles.themeSwitch ?? ""} />
       </div>
-    </MaxWidth>
+    </div>
   </header>
 );

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

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

+ 5 - 11
apps/insights/src/components/Root/index.tsx

@@ -1,12 +1,11 @@
-import { sans } from "@pythnetwork/fonts";
 import { Root as BaseRoot } from "@pythnetwork/next-root";
-import clsx from "clsx";
 import { NuqsAdapter } from "nuqs/adapters/next/app";
 import type { ReactNode } from "react";
 
 import { Footer } from "./footer";
 import { Header } from "./header";
-import { MobileMenu } from "./mobile-menu";
+// import { MobileMenu } from "./mobile-menu";
+import styles from "./index.module.scss";
 import { TabPanel, TabRoot } from "./tabs";
 import {
   IS_PRODUCTION_SERVER,
@@ -23,19 +22,14 @@ export const Root = ({ children }: Props) => (
     amplitudeApiKey={AMPLITUDE_API_KEY}
     googleAnalyticsId={GOOGLE_ANALYTICS_ID}
     enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
-    bodyClassName={clsx(
-      "bg-white font-sans text-steel-900 antialiased selection:bg-violet-600 selection:text-steel-50 dark:bg-steel-950 dark:text-steel-50 dark:selection:bg-violet-400 dark:selection:text-steel-950",
-      sans.variable,
-    )}
     providers={[NuqsAdapter]}
   >
-    <TabRoot className="grid min-h-dvh grid-rows-[auto_1fr_auto]">
-      <Header />
-      <main className="pb-12 pt-6">
+    <TabRoot className={styles.tabRoot ?? ""}>
+      <Header className={styles.header} />
+      <main className={styles.main}>
         <TabPanel>{children}</TabPanel>
       </main>
       <Footer />
-      <MobileMenu />
     </TabRoot>
   </BaseRoot>
 );

+ 5 - 7
apps/insights/src/components/Root/mobile-menu.tsx

@@ -1,11 +1,9 @@
 import type { Icon } from "@phosphor-icons/react";
-import {
-  PresentationChart,
-  Broadcast,
-  ChartLine,
-  MagnifyingGlass,
-  List,
-} from "@phosphor-icons/react/dist/ssr";
+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";
 
 import { NavLink } from "./nav-link";

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 15
apps/insights/src/components/Root/orb.svg


+ 26 - 0
apps/insights/src/components/Root/search-button.tsx

@@ -0,0 +1,26 @@
+"use client";
+
+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 { useIsSSR } from "react-aria";
+
+export const SearchButton = () => (
+  <Button beforeIcon={MagnifyingGlass} variant="outline" size="sm" rounded>
+    <SearchText />
+  </Button>
+);
+
+const SearchText = () => {
+  const isSSR = useIsSSR();
+  return isSSR ? <Skeleton width={7} /> : <SearchTextImpl />;
+};
+
+const SearchTextImpl = () => {
+  // This component can only ever render in the client so we can safely ignore
+  // this eslint rule.
+  // eslint-disable-next-line n/no-unsupported-features/node-builtins
+  const isMac = useMemo(() => navigator.userAgent.includes("Mac"), []);
+  return isMac ? "⌘ K" : "Ctrl K";
+};

+ 7 - 75
apps/insights/src/components/Root/tabs.tsx

@@ -1,92 +1,24 @@
 "use client";
 
-import type { Icon } from "@phosphor-icons/react";
 import {
-  PresentationChart,
-  Broadcast,
-  ChartLine,
-} from "@phosphor-icons/react/dist/ssr";
-import clsx from "clsx";
-import { m, LazyMotion, domMax } from "framer-motion";
+  UnstyledTabPanel,
+  UnstyledTabs,
+} from "@pythnetwork/component-library/UnstyledTabs";
 import { useSelectedLayoutSegment } from "next/navigation";
 import type { ComponentProps } from "react";
-import {
-  Tab as BaseTab,
-  TabPanel as BaseTabPanel,
-  TabList as BaseTabList,
-  Tabs,
-} from "react-aria-components";
 
 export const TabRoot = (
-  props: Omit<ComponentProps<typeof Tabs>, "selectedKey">,
+  props: Omit<ComponentProps<typeof UnstyledTabs>, "selectedKey">,
 ) => {
   const layoutSegment = useSelectedLayoutSegment();
 
-  return <Tabs selectedKey={`/${layoutSegment ?? ""}`} {...props} />;
+  return <UnstyledTabs selectedKey={`/${layoutSegment ?? ""}`} {...props} />;
 };
 
 export const TabPanel = (
-  props: Omit<ComponentProps<typeof BaseTabPanel>, "id">,
+  props: Omit<ComponentProps<typeof UnstyledTabPanel>, "id">,
 ) => {
   const layoutSegment = useSelectedLayoutSegment();
 
-  return <BaseTabPanel id={`/${layoutSegment ?? ""}`} {...props} />;
+  return <UnstyledTabPanel id={`/${layoutSegment ?? ""}`} {...props} />;
 };
-
-export const TabList = () => (
-  <LazyMotion features={domMax} strict>
-    <BaseTabList
-      aria-label="Main Navigation"
-      className="hidden flex-row items-center gap-2 lg:flex"
-    >
-      <Tab href="/" icon={PresentationChart}>
-        Overview
-      </Tab>
-      <Tab href="/publishers" icon={Broadcast}>
-        Publishers
-      </Tab>
-      <Tab href="/price-feeds" icon={ChartLine}>
-        Price Feeds
-      </Tab>
-    </BaseTabList>
-  </LazyMotion>
-);
-
-type TabProps = Omit<
-  ComponentProps<typeof BaseTab>,
-  "id" | "href" | "children"
-> & {
-  icon: Icon;
-  href: string;
-  children: string;
-};
-
-const Tab = ({ href, className, children, icon: Icon, ...props }: TabProps) => (
-  <BaseTab
-    className={clsx(
-      "group/tab relative h-9 cursor-pointer whitespace-nowrap rounded-lg border border-transparent px-button-padding-sm text-sm font-medium leading-9 text-stone-900 outline-none data-[selected]:cursor-default data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 dark:text-steel-50 dark:data-[hovered]:bg-white/5 dark:data-[pressed]:bg-white/10",
-      className,
-    )}
-    id={href}
-    href={href}
-    {...props}
-  >
-    {(args) => (
-      <>
-        {args.isSelected && (
-          <m.span
-            layoutId="bubble"
-            // @ts-expect-error looks like framer-motion isn't typed correctly
-            className="absolute inset-0 z-10 rounded-lg bg-white mix-blend-difference outline-2 outline-offset-2 outline-white group-data-[focus-visible]/tab:outline"
-            transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
-            style={{ originY: "top" }}
-          />
-        )}
-        <span className="inline-grid h-full place-content-center align-top">
-          <Icon className="relative size-5" />
-        </span>
-        <span className="px-2">{children}</span>
-      </>
-    )}
-  </BaseTab>
-);

+ 29 - 0
apps/insights/src/components/Root/theme-switch.module.scss

@@ -0,0 +1,29 @@
+@use "@pythnetwork/component-library/theme.scss";
+
+.themeSwitch {
+  overflow: hidden;
+
+  .iconPath {
+    position: relative;
+
+    .iconPlaceholder,
+    .iconMovement {
+      width: 100%;
+      height: 100%;
+    }
+
+    .iconMovement {
+      offset-path: circle(theme.spacing(12) at -#{theme.spacing(12)});
+      offset-rotate: auto -90deg;
+      offset-anchor: left;
+      position: absolute;
+      top: 0;
+      right: 0;
+
+      .icon {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+}

+ 106 - 19
apps/insights/src/components/Root/theme-switch.tsx

@@ -1,40 +1,127 @@
 "use client";
 
-import { Sun, Moon } from "@phosphor-icons/react/dist/ssr";
+import type { IconProps } from "@phosphor-icons/react";
+import { Desktop } from "@phosphor-icons/react/dist/ssr/Desktop";
+import { Moon } from "@phosphor-icons/react/dist/ssr/Moon";
+import { Sun } from "@phosphor-icons/react/dist/ssr/Sun";
 import { Button } from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
+import { m, LazyMotion, domMax } from "framer-motion";
 import { useTheme } from "next-themes";
-import { type ComponentProps, useCallback } from "react";
+import {
+  type ComponentProps,
+  useCallback,
+  useRef,
+  useMemo,
+  type ComponentType,
+} from "react";
+import { useIsSSR } from "react-aria";
+
+import styles from "./theme-switch.module.scss";
 
 type Props = Omit<
   ComponentProps<typeof Button>,
   "beforeIcon" | "variant" | "size" | "hideText" | "children" | "onPress"
 >;
 
-export const ThemeSwitch = (props: Props) => {
+export const ThemeSwitch = ({ className, ...props }: Props) => {
   const { theme, setTheme } = useTheme();
 
   const toggleTheme = useCallback(() => {
-    setTheme(theme === "dark" ? "light" : "dark");
+    const nextThemeName = nextTheme(theme);
+    setTheme(nextThemeName);
   }, [theme, setTheme]);
 
   return (
-    <Button
-      variant="ghost"
-      size="sm"
-      hideText
-      onPress={toggleTheme}
-      beforeIcon={Icon}
-      {...props}
-    >
-      Dark mode
-    </Button>
+    <LazyMotion features={domMax} strict>
+      <Button
+        variant="ghost"
+        size="sm"
+        hideText
+        onPress={toggleTheme}
+        beforeIcon={IconPath}
+        className={clsx(styles.themeSwitch, className)}
+        rounded
+        {...props}
+      >
+        Dark mode
+      </Button>
+    </LazyMotion>
   );
 };
 
-const Icon = ({ className, ...props }: ComponentProps<typeof Sun>) => (
-  <>
-    <Sun className={clsx("hidden dark:block", className)} {...props} />
-    <Moon className={clsx("dark:hidden", className)} {...props} />
-  </>
+const IconPath = ({ className, ...props }: Omit<IconProps, "offset">) => {
+  const offsets = useOffsets();
+  const isSSR = useIsSSR();
+
+  return isSSR ? (
+    <div className={className} />
+  ) : (
+    <div className={clsx(styles.iconPath, className)}>
+      <IconMovement icon={Desktop} offset={offsets.desktop} {...props} />
+      <IconMovement icon={Sun} offset={offsets.sun} {...props} />
+      <IconMovement icon={Moon} offset={offsets.moon} {...props} />
+    </div>
+  );
+};
+
+type IconMovementProps = Omit<IconProps, "offset"> & {
+  icon: ComponentType<IconProps>;
+  offset: string;
+};
+
+const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => (
+  <m.div
+    // @ts-expect-error Looks like framer-motion has a bug in it's typings...
+    className={styles.iconMovement}
+    animate={{ offsetDistance: offset }}
+    transition={{ type: "spring", bounce: 0.35, duration: 0.6 }}
+    initial={false}
+  >
+    <Icon className={styles.icon} {...props} />
+  </m.div>
 );
+
+const useOffsets = () => {
+  const numRotations = useRef(1);
+  const prevTheme = useRef<string | undefined>(undefined);
+  const { theme } = useTheme();
+
+  if (theme !== prevTheme.current) {
+    prevTheme.current = theme;
+    if (theme === "light") {
+      numRotations.current += 1;
+    }
+  }
+
+  return useMemo(() => {
+    const calc = (offset: number) =>
+      `${(100 * (numRotations.current + offset)).toString()}%`;
+
+    switch (theme) {
+      case "light": {
+        return { desktop: calc(1 / 3), sun: calc(0), moon: calc(-1 / 3) };
+      }
+      case "dark": {
+        return { desktop: calc(2 / 3), sun: calc(1 / 3), moon: calc(0) };
+      }
+      default: {
+        return { desktop: calc(1), sun: calc(2 / 3), moon: calc(1 / 3) };
+      }
+    }
+  }, [theme]);
+};
+
+const nextTheme = (theme: string | undefined) => {
+  switch (theme) {
+    case "system": {
+      return "light";
+    }
+    case "light": {
+      return "dark";
+    }
+    default: {
+      return "system";
+    }
+  }
+};

+ 0 - 3
apps/insights/src/tailwind.css

@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;

+ 0 - 11
apps/insights/tailwind.config.ts

@@ -1,11 +0,0 @@
-import { tailwindGlob } from "@pythnetwork/component-library";
-import componentLibraryConfig from "@pythnetwork/component-library/tailwind-config";
-import type { Config } from "tailwindcss";
-
-const tailwindConfig = {
-  content: [tailwindGlob, "src/components/**/*.{ts,tsx}"],
-  presets: [componentLibraryConfig],
-  darkMode: "selector",
-} satisfies Config;
-
-export default tailwindConfig;

+ 1 - 1
apps/staking/package.json

@@ -14,7 +14,7 @@
     "start:dev": "next dev --port 3001",
     "start:prod": "next start --port 3001",
     "test:format": "prettier --check .",
-    "test:lint": "jest --selectProjects lint",
+    "test:lint": "eslint .",
     "test:types": "tsc",
     "test:unit": "jest --selectProjects unit"
   },

+ 1 - 1
governance/pyth_staking_sdk/package.json

@@ -16,7 +16,7 @@
     "fix:lint": "eslint --fix .",
     "prepublishOnly": "node scripts/update-package-json.mjs",
     "test:format": "prettier --check .",
-    "test:lint": "jest --selectProjects lint",
+    "test:lint": "eslint .",
     "test:types": "tsc"
   },
   "devDependencies": {

+ 0 - 1
lazer/sdk/js/eslint.config.js

@@ -1,2 +1 @@
-/* eslint-disable n/no-unpublished-import */
 export { base as default } from "@cprussin/eslint-config";

+ 2 - 2
packages/app-logger/package.json

@@ -10,8 +10,8 @@
   "scripts": {
     "fix:format": "prettier --write .",
     "fix:lint": "eslint --fix .",
-    "test:format": "jest --selectProjects format",
-    "test:lint": "jest --selectProjects lint",
+    "test:format": "prettier --check .",
+    "test:lint": "eslint .",
     "test:types": "tsc"
   },
   "peerDependencies": {

+ 15 - 4
packages/component-library/.storybook/main.ts

@@ -13,6 +13,10 @@ const config = {
     "../src/**/?(*.)stories.tsx",
   ],
 
+  features: {
+    backgroundsStoryGlobals: true,
+  },
+
   addons: [
     "@storybook/addon-essentials",
     "@storybook/addon-themes",
@@ -21,16 +25,23 @@ const config = {
       options: {
         rules: [
           {
-            test: /\.css$/,
+            test: /\.s[ac]ss$/i,
             use: [
               "style-loader",
               {
                 loader: "css-loader",
-                options: { importLoaders: 1 },
+                options: {
+                  modules: {
+                    auto: true,
+                    localIdentName: "[name]__[local]--[hash:base64:5]",
+                  },
+                  importLoaders: 1,
+                  esModule: false,
+                },
               },
               {
-                loader: "postcss-loader",
-                options: { implementation: resolve("postcss") },
+                loader: "sass-loader",
+                options: { implementation: resolve("sass") },
               },
             ],
           },

+ 0 - 8
packages/component-library/.storybook/postcss.config.cjs

@@ -1,8 +0,0 @@
-module.exports = {
-  plugins: {
-    tailwindcss: {
-      config: `${__dirname}/../tailwind.config.ts`,
-    },
-    autoprefixer: {},
-  },
-};

+ 14 - 31
packages/component-library/.storybook/preview.tsx

@@ -1,49 +1,32 @@
-import { sans } from "@pythnetwork/fonts";
 import { withThemeByClassName } from "@storybook/addon-themes";
 import type { Preview, Decorator } from "@storybook/react";
-import { useEffect, type ComponentType } from "react";
 
-import "./tailwind.css";
+import "../src/Html/base.scss";
+import styles from "./storybook.module.scss";
 
 const preview = {
   parameters: {
-    backgrounds: { disable: true },
+    backgrounds: {
+      options: [
+        { name: "Primary", value: "var(--primary-background)" },
+        { name: "Secondary", value: "var(--secondary-background)" },
+      ],
+    },
     actions: { argTypesRegex: "^on[A-Z].*" },
   },
+  initialGlobals: {
+    backgrounds: { value: "Primary" },
+  },
 } satisfies Preview;
 
 export default preview;
 
-const withRootClasses = (...classes: string[]): Decorator => {
-  const WithRootClasses = (Story: ComponentType) => {
-    useEffect(() => {
-      const root = document.querySelector("html");
-      const classList = classes
-        .flatMap((cls) => cls.split(" "))
-        .filter(Boolean);
-      if (root) {
-        root.classList.add(...classList);
-        return () => {
-          root.classList.remove(...classList);
-        };
-      } else {
-        return;
-      }
-    }, []);
-    return <Story />;
-  };
-  return WithRootClasses;
-};
-
 export const decorators: Decorator[] = [
-  withRootClasses("font-sans antialiased", sans.variable),
   withThemeByClassName({
     themes: {
-      white: "light bg-white text-steel-900",
-      light: "light bg-beige-100 text-steel-900",
-      dark: "dark bg-steel-800 text-steel-50",
-      darker: "dark bg-steel-900 text-steel-50",
+      Light: styles.light ?? "",
+      Dark: styles.dark ?? "",
     },
-    defaultTheme: "light",
+    defaultTheme: "Light",
   }),
 ];

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

@@ -0,0 +1,19 @@
+@use "../src/theme.scss";
+
+html,
+body {
+  height: 100%;
+}
+
+:root {
+  --primary-background: #{theme.color("background", "primary")};
+  --secondary-background: #{theme.color("background", "secondary")};
+}
+
+.light {
+  color-scheme: light;
+}
+
+.dark {
+  color-scheme: dark;
+}

+ 0 - 3
packages/component-library/.storybook/tailwind.css

@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;

+ 2 - 8
packages/component-library/eslint.config.js

@@ -1,10 +1,4 @@
-import { fileURLToPath } from "node:url";
+import { react, storybook } from "@cprussin/eslint-config";
 
-import { react, tailwind, storybook } from "@cprussin/eslint-config";
-
-const config = [
-  ...react,
-  ...tailwind(fileURLToPath(import.meta.resolve("./tailwind.config.ts"))),
-  ...storybook,
-];
+const config = [...react, ...storybook];
 export default config;

+ 8 - 9
packages/component-library/package.json

@@ -7,9 +7,8 @@
     "node": "20"
   },
   "exports": {
-    ".": "./src/index.ts",
-    "./tailwind-config": "./tailwind.config.ts",
-    "./*": "./src/*/index.tsx"
+    "./*": "./src/*/index.tsx",
+    "./theme.scss": "./src/theme.scss"
   },
   "scripts": {
     "build:storybook": "storybook build",
@@ -17,15 +16,18 @@
     "fix:lint": "eslint --fix .",
     "start:dev": "storybook dev --port 4000 --no-open",
     "test:format": "prettier --check .",
-    "test:lint": "jest --selectProjects lint",
+    "test:lint": "eslint .",
     "test:types": "tsc"
   },
   "peerDependencies": {
     "react": "catalog:"
   },
   "dependencies": {
+    "@pythnetwork/fonts": "workspace:*",
     "@react-hookz/web": "catalog:",
     "clsx": "catalog:",
+    "modern-normalize": "catalog:",
+    "motion": "catalog:",
     "react-aria": "catalog:",
     "react-aria-components": "catalog:"
   },
@@ -35,14 +37,12 @@
     "@cprussin/prettier-config": "catalog:",
     "@cprussin/tsconfig": "catalog:",
     "@phosphor-icons/react": "catalog:",
-    "@pythnetwork/fonts": "workspace:*",
     "@storybook/addon-essentials": "catalog:",
     "@storybook/addon-styling-webpack": "catalog:",
     "@storybook/addon-themes": "catalog:",
     "@storybook/blocks": "catalog:",
     "@storybook/nextjs": "catalog:",
     "@storybook/react": "catalog:",
-    "@tailwindcss/forms": "catalog:",
     "@types/jest": "catalog:",
     "@types/react": "catalog:",
     "autoprefixer": "catalog:",
@@ -53,11 +53,10 @@
     "postcss-loader": "catalog:",
     "prettier": "catalog:",
     "react": "catalog:",
+    "sass": "catalog:",
+    "sass-loader": "catalog:",
     "storybook": "catalog:",
     "style-loader": "catalog:",
-    "tailwindcss": "catalog:",
-    "tailwindcss-animate": "catalog:",
-    "tailwindcss-react-aria-components": "catalog:",
     "typescript": "catalog:"
   }
 }

+ 1 - 10
packages/component-library/prettier.config.js

@@ -1,10 +1 @@
-import { fileURLToPath } from "node:url";
-
-import { base, tailwind, mergeConfigs } from "@cprussin/prettier-config";
-
-const config = mergeConfigs([
-  base,
-  tailwind(fileURLToPath(import.meta.resolve("./tailwind.config.ts"))),
-]);
-
-export default config;
+export { base as default } from "@cprussin/prettier-config";

+ 4 - 0
packages/component-library/scss.d.ts

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

+ 31 - 0
packages/component-library/src/AppTabs/index.module.scss

@@ -0,0 +1,31 @@
+@use "../theme.scss";
+
+.appTabs {
+  gap: theme.spacing(2);
+  @include theme.row;
+
+  .tab {
+    position: relative;
+    outline: none;
+
+    .bubble {
+      position: absolute;
+      inset: 0;
+      border-radius: theme.border-radius("full");
+      background-color: white;
+      mix-blend-mode: difference;
+      outline: none;
+      z-index: 1;
+      transition: box-shadow 200ms linear;
+    }
+
+    &[data-focus-visible] {
+      box-shadow: none;
+      border-color: transparent;
+
+      .bubble {
+        box-shadow: 0px 0px 0px 4px rgba(white, 0.4);
+      }
+    }
+  }
+}

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

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

+ 50 - 0
packages/component-library/src/AppTabs/index.tsx

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

+ 0 - 61
packages/component-library/src/Button/arg-types.ts

@@ -1,61 +0,0 @@
-import * as Icon from "@phosphor-icons/react/dist/ssr";
-import type { ArgTypes } from "@storybook/react";
-
-import { VARIANTS, SIZES } from "./index.js";
-
-export const Category = {
-  State: "State",
-  Variant: "Variant",
-  Contents: "Contents",
-};
-
-export const argTypes = {
-  children: {
-    control: "text",
-    table: {
-      category: Category.Contents,
-    },
-  },
-  isDisabled: {
-    control: "boolean",
-    table: {
-      category: Category.State,
-    },
-  },
-  variant: {
-    control: "inline-radio",
-    options: VARIANTS,
-    table: {
-      category: Category.Variant,
-    },
-  },
-  size: {
-    control: "inline-radio",
-    options: SIZES,
-    table: {
-      category: Category.Variant,
-    },
-  },
-  rounded: {
-    control: "boolean",
-    table: {
-      category: Category.Variant,
-    },
-  },
-  beforeIcon: {
-    control: "select",
-    options: Object.keys(Icon),
-    mapping: Icon,
-    table: {
-      category: Category.Contents,
-    },
-  },
-  afterIcon: {
-    control: "select",
-    options: Object.keys(Icon),
-    mapping: Icon,
-    table: {
-      category: Category.Contents,
-    },
-  },
-} satisfies ArgTypes;

+ 4 - 1
packages/component-library/src/Button/button-link.stories.tsx

@@ -1,8 +1,10 @@
 import type { Meta, StoryObj } from "@storybook/react";
 
-import { argTypes } from "./arg-types.js";
 import { ButtonLink as ButtonLinkComponent } from "./index.js";
+import buttonMeta from "./index.stories.js";
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const { onPress, isPending, ...argTypes } = buttonMeta.argTypes;
 const meta = {
   component: ButtonLinkComponent,
   title: "Button/ButtonLink",
@@ -33,5 +35,6 @@ export const ButtonLink = {
     size: "md",
     isDisabled: false,
     rounded: false,
+    hideText: false,
   },
 } satisfies StoryObj<typeof ButtonLinkComponent>;

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

@@ -0,0 +1,134 @@
+@use "../theme.scss";
+
+.button {
+  display: inline flow-root;
+  cursor: pointer;
+  white-space: nowrap;
+  font-weight: theme.font-weight("medium");
+  outline: none;
+  border: none;
+  transition-property: background-color, color, border-color, box-shadow;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  border: 1px solid transparent;
+  text-decoration: none;
+
+  .iconWrapper {
+    display: inline-grid;
+    height: 100%;
+    place-content: center;
+    vertical-align: top;
+  }
+
+  @each $size, $values in theme.$button-sizes {
+    &[data-size="#{$size}"] {
+      height: theme.map-get-strict($values, "height");
+      border-radius: theme.map-get-strict($values, "border-radius");
+      padding: 0 theme.map-get-strict($values, "padding");
+      font-size: theme.map-get-strict($values, "font-size");
+
+      .icon {
+        width: theme.map-get-strict($values, "icon-size");
+        height: theme.map-get-strict($values, "icon-size");
+      }
+
+      .text {
+        padding: 0 theme.map-get-strict($values, "gap");
+        line-height: calc(theme.map-get-strict($values, "height") - 2px);
+      }
+    }
+  }
+
+  &[data-rounded] {
+    border-radius: theme.border-radius("full");
+  }
+
+  @each $variant in ("primary", "secondary", "solid") {
+    &[data-variant="#{$variant}"] {
+      background-color: theme.color("button", $variant, "background", "normal");
+      color: theme.color("button", $variant, "foreground");
+
+      &[data-hovered] {
+        background-color: theme.color(
+          "button",
+          $variant,
+          "background",
+          "hover"
+        );
+      }
+
+      &[data-pressed] {
+        background-color: theme.color(
+          "button",
+          $variant,
+          "background",
+          "active"
+        );
+      }
+    }
+  }
+
+  &[data-variant="outline"],
+  &[data-variant="ghost"] {
+    background-color: transparent;
+    color: theme.color("button", "outline", "foreground");
+
+    &[data-hovered] {
+      background-color: theme.color("button", "outline", "background", "hover");
+    }
+
+    &[data-pressed] {
+      background-color: theme.color(
+        "button",
+        "outline",
+        "background",
+        "active"
+      );
+    }
+  }
+
+  @each $variant, $state in ("success": "success", "danger": "error") {
+    &[data-variant="#{$variant}"] {
+      background-color: theme.color("states", $state, "normal");
+      color: theme.color("button", "primary", "foreground");
+
+      &[data-hovered] {
+        background-color: theme.color("states", $state, "hover");
+      }
+
+      &[data-pressed] {
+        background-color: theme.color("states", $state, "active");
+      }
+    }
+  }
+
+  &[data-variant="outline"] {
+    border-color: theme.color("button", "outline", "border");
+  }
+
+  &[data-disabled],
+  &[data-pending] {
+    border-color: transparent;
+    background-color: theme.color("button", "disabled", "background");
+    color: theme.color("button", "disabled", "foreground");
+  }
+
+  &[data-disabled] {
+    cursor: not-allowed;
+  }
+
+  &[data-pending] {
+    cursor: wait;
+  }
+
+  &[data-text-hidden] {
+    .text {
+      @include theme.sr-only;
+    }
+  }
+
+  &[data-focus-visible] {
+    border-color: theme.color("forms", "input", "focus", "border");
+    box-shadow: 0px 0px 0px 4px theme.color("forms", "focus-color");
+  }
+}

+ 58 - 4
packages/component-library/src/Button/index.stories.tsx

@@ -1,12 +1,65 @@
+import * as Icon from "@phosphor-icons/react/dist/ssr";
 import type { Meta, StoryObj } from "@storybook/react";
 
-import { Category, argTypes } from "./arg-types.js";
-import { Button as ButtonComponent } from "./index.js";
+import { Button as ButtonComponent, VARIANTS, SIZES } from "./index.js";
 
 const meta = {
   component: ButtonComponent,
   argTypes: {
-    ...argTypes,
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    isDisabled: {
+      control: "boolean",
+      table: {
+        category: "State",
+      },
+    },
+    variant: {
+      control: "inline-radio",
+      options: VARIANTS,
+      table: {
+        category: "Variant",
+      },
+    },
+    size: {
+      control: "inline-radio",
+      options: SIZES,
+      table: {
+        category: "Variant",
+      },
+    },
+    rounded: {
+      control: "boolean",
+      table: {
+        category: "Variant",
+      },
+    },
+    hideText: {
+      control: "boolean",
+      table: {
+        category: "Contents",
+      },
+    },
+    beforeIcon: {
+      control: "select",
+      options: Object.keys(Icon),
+      mapping: Icon,
+      table: {
+        category: "Contents",
+      },
+    },
+    afterIcon: {
+      control: "select",
+      options: Object.keys(Icon),
+      mapping: Icon,
+      table: {
+        category: "Contents",
+      },
+    },
     onPress: {
       table: {
         category: "Behavior",
@@ -15,7 +68,7 @@ const meta = {
     isPending: {
       control: "boolean",
       table: {
-        category: Category.State,
+        category: "State",
       },
     },
   },
@@ -30,5 +83,6 @@ export const Button = {
     isDisabled: false,
     isPending: false,
     rounded: false,
+    hideText: false,
   },
 } satisfies StoryObj<typeof ButtonComponent>;

+ 24 - 99
packages/component-library/src/Button/index.tsx

@@ -5,6 +5,7 @@ import {
   type LinkProps as BaseLinkProps,
 } from "react-aria-components";
 
+import styles from "./index.module.scss";
 import { UnstyledButton } from "../UnstyledButton/index.js";
 import { UnstyledLink } from "../UnstyledLink/index.js";
 
@@ -24,42 +25,28 @@ type OwnProps = {
   size?: (typeof SIZES)[number] | undefined;
   rounded?: boolean | undefined;
   hideText?: boolean | undefined;
-  children: string;
+  children: ReactNode;
   beforeIcon?: Icon | undefined;
   afterIcon?: Icon | undefined;
 };
 
 export type ButtonProps = Omit<BaseButtonProps, keyof OwnProps> & OwnProps;
 
-export const Button = ({ className, ...props }: ButtonProps) => (
-  <ButtonImpl
-    component={UnstyledButton}
-    className={clsx(
-      // Pending
-      "data-[pending]:data-[variant]:cursor-wait data-[pending]:data-[variant]:border-transparent data-[pending]:data-[variant]:bg-stone-200 data-[pending]:data-[variant]:text-stone-400 data-[pending]:data-[variant]:data-[focus-visible]:outline-stone-300 dark:data-[pending]:data-[variant]:bg-steel-600 dark:data-[pending]:data-[variant]:text-steel-400 dark:data-[pending]:data-[variant]:outline-steel-500",
-
-      className,
-    )}
-    {...props}
-  />
+export const Button = (props: ButtonProps) => (
+  <UnstyledButton {...buttonProps(props)} />
 );
 
 export type ButtonLinkProps = Omit<BaseLinkProps, keyof OwnProps> & OwnProps;
 
 export const ButtonLink = (props: ButtonLinkProps) => (
-  <ButtonImpl component={UnstyledLink} {...props} />
+  <UnstyledLink {...buttonProps(props)} />
 );
 
 type ButtonImplProps = OwnProps & {
   className?: Parameters<typeof clsx>[0];
-  component: ComponentType<{
-    className: ReturnType<typeof clsx>;
-    children: ReactNode[];
-  }>;
 };
 
-const ButtonImpl = ({
-  component: Component,
+const buttonProps = ({
   variant = "primary",
   size = "md",
   rounded = false,
@@ -69,88 +56,26 @@ const ButtonImpl = ({
   afterIcon,
   hideText = false,
   ...inputProps
-}: ButtonImplProps) => (
-  <Component
-    {...inputProps}
-    data-variant={variant}
-    data-size={size}
-    data-rounded={rounded ? "" : undefined}
-    data-text-hidden={hideText ? "" : undefined}
-    className={clsx(baseClasses, className)}
-  >
-    {beforeIcon !== undefined && <Icon icon={beforeIcon} />}
-    <span className="group-data-[text-hidden]/button:sr-only group-data-[size=lg]/button:px-3 group-data-[size=md]/button:px-2 group-data-[size=sm]/button:px-2 group-data-[size=xs]/button:px-1 group-data-[size=lg]/button:leading-[3.5rem] group-data-[size=md]/button:leading-[3rem] group-data-[size=sm]/button:leading-9 group-data-[size=xs]/button:leading-6">
-      {children}
-    </span>
-    {afterIcon !== undefined && <Icon icon={afterIcon} />}
-  </Component>
-);
+}: ButtonImplProps) => ({
+  ...inputProps,
+  "data-variant": variant,
+  "data-size": size,
+  "data-rounded": rounded ? "" : undefined,
+  "data-text-hidden": hideText ? "" : undefined,
+  className: clsx(styles.button, className),
+  children: (
+    <>
+      {beforeIcon !== undefined && <Icon icon={beforeIcon} />}
+      <span className={styles.text}>{children}</span>
+      {afterIcon !== undefined && <Icon icon={afterIcon} />}
+    </>
+  ),
+});
 
 const Icon = ({ icon: IconComponent }: { icon: Icon }) => (
-  <span className="inline-grid h-full place-content-center align-top">
-    <IconComponent className="relative group-data-[size=lg]/button:size-6 group-data-[size=md]/button:size-6 group-data-[size=sm]/button:size-5 group-data-[size=xs]/button:size-4" />
+  <span className={styles.iconWrapper}>
+    <IconComponent className={styles.icon} />
   </span>
 );
 
-const baseClasses = clsx(
-  "group/button inline-block cursor-pointer whitespace-nowrap font-medium outline-none outline-0 transition-colors duration-100 data-[size]:data-[rounded]:rounded-full data-[focus-visible]:outline-2",
-
-  // xs
-  "data-[size=xs]:h-6 data-[size=xs]:rounded-md data-[size=xs]:px-button-padding-xs data-[size=xs]:text-[0.6875rem]",
-
-  // sm
-  "data-[size=sm]:h-9 data-[size=sm]:rounded-lg data-[size=sm]:px-button-padding-sm data-[size=sm]:text-sm",
-
-  // md (default)
-  "data-[size=md]:h-12 data-[size=md]:rounded-xl data-[size=md]:px-3 data-[size=md]:text-base",
-
-  // lg
-  "data-[size=lg]:h-14 data-[size=lg]:rounded-2xl data-[size=lg]:px-4 data-[size=lg]:text-xl",
-
-  // Primary (default)
-  "data-[variant=primary]:bg-violet-700 data-[variant=primary]:data-[hovered]:bg-violet-800 data-[variant=primary]:data-[pressed]:bg-violet-900 data-[variant=primary]:text-white data-[variant=primary]:outline-violet-700",
-
-  // Dark Mode Primary (default)
-  "dark:data-[variant=primary]:bg-violet-600 dark:data-[variant=primary]:data-[hovered]:bg-violet-700 dark:data-[variant=primary]:data-[pressed]:bg-violet-800 dark:data-[variant=primary]:text-white dark:data-[variant=primary]:outline-violet-600",
-
-  // Secondary
-  "data-[variant=secondary]:bg-purple-200 data-[variant=secondary]:data-[hovered]:bg-purple-300 data-[variant=secondary]:data-[pressed]:bg-purple-400 data-[variant=secondary]:text-steel-900 data-[variant=secondary]:outline-purple-300",
-
-  // Dark Mode Secondary
-  "dark:data-[variant=secondary]:bg-purple-200 dark:data-[variant=secondary]:data-[hovered]:bg-purple-300 dark:data-[variant=secondary]:data-[pressed]:bg-purple-400 dark:data-[variant=secondary]:text-steel-900 dark:data-[variant=secondary]:outline-purple-300",
-
-  // Solid
-  "data-[variant=solid]:bg-steel-900 data-[variant=solid]:data-[hovered]:bg-steel-600 data-[variant=solid]:data-[pressed]:bg-steel-900 data-[variant=solid]:text-steel-50 data-[variant=solid]:outline-steel-600",
-
-  // Dark Mode Solid
-  "dark:data-[variant=solid]:bg-steel-50 dark:data-[variant=solid]:data-[hovered]:bg-steel-200 dark:data-[variant=solid]:data-[pressed]:bg-steel-50 dark:data-[variant=solid]:text-steel-900 dark:data-[variant=solid]:outline-steel-300",
-
-  // Outline
-  "data-[variant=outline]:border data-[variant=outline]:border-stone-300 data-[variant=outline]:bg-transparent data-[variant=outline]:data-[hovered]:bg-black/5 data-[variant=outline]:data-[pressed]:bg-black/10 data-[variant=outline]:text-stone-900 data-[variant=outline]:outline-steel-600",
-
-  // Dark Mode Outline
-  "dark:data-[variant=outline]:border-steel-600 dark:data-[variant=outline]:bg-transparent dark:data-[variant=outline]:data-[hovered]:bg-white/5 dark:data-[variant=outline]:data-[pressed]:bg-white/10 dark:data-[variant=outline]:text-steel-50 dark:data-[variant=outline]:outline-steel-300",
-
-  // Ghost
-  "data-[variant=ghost]:bg-transparent data-[variant=ghost]:data-[hovered]:bg-black/5 data-[variant=ghost]:data-[pressed]:bg-black/10 data-[variant=ghost]:text-stone-900 data-[variant=ghost]:outline-steel-600",
-
-  // Dark Mode Ghost
-  "dark:data-[variant=ghost]:bg-transparent dark:data-[variant=ghost]:data-[hovered]:bg-white/5 dark:data-[variant=ghost]:data-[pressed]:bg-white/10 dark:data-[variant=ghost]:text-steel-50 dark:data-[variant=ghost]:outline-steel-300",
-
-  // Success
-  "data-[variant=success]:bg-emerald-500 data-[variant=success]:data-[hovered]:bg-emerald-600 data-[variant=success]:data-[pressed]:bg-emerald-700 data-[variant=success]:text-violet-50 data-[variant=success]:outline-emerald-500",
-
-  // Dark Mode Success
-  "dark:data-[variant=success]:bg-emerald-500 dark:data-[variant=success]:data-[hovered]:bg-emerald-600 dark:data-[variant=success]:data-[pressed]:bg-emerald-700 dark:data-[variant=success]:text-violet-50 dark:data-[variant=success]:outline-emerald-500",
-
-  // Danger
-  "data-[variant=danger]:bg-red-500 data-[variant=danger]:data-[hovered]:bg-red-600 data-[variant=danger]:data-[pressed]:bg-red-700 data-[variant=danger]:text-violet-50 data-[variant=danger]:outline-red-500",
-
-  // Dark Mode Danger
-  "dark:data-[variant=danger]:bg-red-500 dark:data-[variant=danger]:data-[hovered]:bg-red-600 dark:data-[variant=danger]:data-[pressed]:bg-red-700 dark:data-[variant=danger]:text-violet-50 dark:data-[variant=danger]:outline-red-500",
-
-  // Disabled
-  "data-[disabled]:data-[variant]:cursor-not-allowed data-[disabled]:data-[variant]:border-transparent data-[disabled]:data-[variant]:bg-stone-200 data-[disabled]:data-[variant]:text-stone-400 dark:data-[disabled]:data-[variant]:bg-steel-600 dark:data-[disabled]:data-[variant]:text-steel-400",
-);
-
-type Icon = ComponentType<{ className: string }>;
+type Icon = ComponentType<{ className?: string | undefined }>;

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

@@ -0,0 +1,10 @@
+@use "../theme.scss";
+
+.card {
+  flex-direction: column;
+  gap: theme.spacing(2);
+  border-radius: theme.border-radius("2xl");
+  background-color: theme.color("background", "secondary");
+  border-radius: theme.border-radius("2xl");
+  padding: theme.spacing(1);
+}

+ 27 - 0
packages/component-library/src/Card/index.stories.tsx

@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Card as CardComponent } from "./index.js";
+
+const meta = {
+  component: CardComponent,
+  parameters: {
+    backgrounds: {
+      disable: true,
+    },
+  },
+  argTypes: {
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+  },
+} satisfies Meta<typeof CardComponent>;
+export default meta;
+
+export const Card = {
+  args: {
+    children: "This is a card!",
+  },
+} satisfies StoryObj<typeof CardComponent>;

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

@@ -1,37 +1,10 @@
 import clsx from "clsx";
-import type { ComponentProps, ReactNode } from "react";
+import type { ComponentProps } from "react";
 
-import { UnstyledToolbar } from "../UnstyledToolbar/index.js";
+import styles from "./index.module.scss";
 
-type Props = ComponentProps<"div"> & {
-  header: ReactNode | ReactNode[];
-  children: ReactNode | ReactNode[];
-  full?: boolean;
-} & (
-    | { toolbar?: undefined }
-    | { toolbar: ReactNode | ReactNode[]; toolbarLabel: string }
-  );
+type Props = ComponentProps<"div">;
 
-export const Card = ({ header, children, full, ...props }: Props) => (
-  <div className="rounded-2xl border border-stone-300 dark:border-steel-600">
-    <div className="flex w-full flex-row items-center justify-between overflow-hidden rounded-t-2xl bg-beige-100 p-4 dark:bg-steel-900">
-      <h2 className="text-lg font-medium">{header}</h2>
-      {props.toolbar && (
-        <UnstyledToolbar
-          aria-label={props.toolbarLabel}
-          className="flex flex-row gap-2"
-        >
-          {props.toolbar}
-        </UnstyledToolbar>
-      )}
-    </div>
-    <div
-      className={clsx({
-        "overflow-hidden rounded-b-2xl bg-beige-100 px-4 pb-4 dark:bg-steel-900":
-          !full,
-      })}
-    >
-      {children}
-    </div>
-  </div>
+export const Card = ({ className, ...props }: Props) => (
+  <div className={clsx(styles.card, className)} {...props} />
 );

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

@@ -0,0 +1,15 @@
+@use "modern-normalize";
+
+@use "../theme.scss";
+
+:root {
+  color: theme.color("foreground");
+  background: theme.color("background", "primary");
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+*::selection {
+  color: theme.color("selection", "foreground");
+  background: theme.color("selection", "background");
+}

+ 9 - 0
packages/component-library/src/Html/index.tsx

@@ -0,0 +1,9 @@
+import { sans } from "@pythnetwork/fonts";
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import "./base.scss";
+
+export const Html = ({ className, lang, ...props }: ComponentProps<"html">) => (
+  <html lang={lang} className={clsx(sans.className, className)} {...props} />
+);

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

@@ -0,0 +1,28 @@
+@use "../theme.scss";
+
+.link {
+  text-decoration: underline;
+  border: none;
+  border-radius: theme.border-radius();
+  outline-width: 1px;
+  outline-offset: theme.spacing(1);
+  outline-color: transparent;
+  color: inherit;
+
+  &[data-focus-visible] {
+    outline-color: currentcolor;
+  }
+
+  &[data-hovered] {
+    text-decoration: none;
+  }
+
+  &:active {
+    color: inherit;
+  }
+
+  &[data-disabled] {
+    cursor: not-allowed;
+    color: theme.color("button", "disabled", "foreground");
+  }
+}

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

@@ -1,14 +1,9 @@
 import clsx from "clsx";
 import type { LinkProps } from "react-aria-components";
 
+import styles from "./index.module.scss";
 import { UnstyledLink } from "../UnstyledLink/index.js";
 
 export const Link = ({ className, ...props }: LinkProps) => (
-  <UnstyledLink
-    className={clsx(
-      "underline outline-0 outline-offset-4 outline-inherit data-[disabled]:cursor-not-allowed data-[disabled]:text-stone-400 data-[disabled]:no-underline data-[focus-visible]:outline-2 hover:no-underline dark:data-[disabled]:text-steel-400",
-      className,
-    )}
-    {...props}
-  />
+  <UnstyledLink className={clsx(styles.link, className)} {...props} />
 );

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

@@ -0,0 +1,36 @@
+@use "../theme.scss";
+
+.paginator {
+  display: flex;
+  flex-flow: row nowrap;
+  justify-content: space-between;
+
+  .pageSizeSelect {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    gap: theme.spacing(1);
+
+    .loadingIndicator {
+      width: theme.spacing(4);
+      height: theme.spacing(4);
+      opacity: 0;
+      transition: opacity 100ms linear;
+      @include theme.spin;
+
+      &.visible {
+        opacity: 1;
+      }
+    }
+  }
+
+  .paginatorToolbar {
+    display: flex;
+    flex-flow: row nowrap;
+    gap: theme.spacing(1);
+
+    .selectedPage {
+      cursor: text;
+    }
+  }
+}

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

@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Paginator as PaginatorComponent } from "./index.js";
+
+const meta = {
+  component: PaginatorComponent,
+  argTypes: {
+    currentPage: {
+      control: "number",
+      table: {
+        category: "Page",
+      },
+    },
+    numPages: {
+      control: "number",
+      table: {
+        category: "Page",
+      },
+    },
+    pageSize: {
+      control: "number",
+      table: {
+        category: "Page",
+      },
+    },
+    pageSizeOptions: {
+      table: {
+        disable: true,
+      },
+    },
+    mkPageLink: {
+      table: {
+        disable: true,
+      },
+    },
+    onPageChange: {
+      table: {
+        category: "Behavior",
+      },
+    },
+    onPageSizeChange: {
+      table: {
+        category: "Behavior",
+      },
+    },
+  },
+} satisfies Meta<typeof PaginatorComponent>;
+export default meta;
+
+export const Paginator = {
+  args: {
+    currentPage: 4,
+    numPages: 8,
+    pageSize: 20,
+    pageSizeOptions: [10, 20, 30, 40, 50],
+  },
+} satisfies StoryObj<typeof PaginatorComponent>;

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

@@ -0,0 +1,276 @@
+import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft";
+import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight";
+import { CircleNotch } from "@phosphor-icons/react/dist/ssr/CircleNotch";
+import clsx from "clsx";
+import {
+  type ComponentProps,
+  useTransition,
+  useMemo,
+  useCallback,
+} from "react";
+
+import styles from "./index.module.scss";
+import { Button, ButtonLink } from "../Button/index.js";
+import buttonStyles from "../Button/index.module.scss";
+import { Select } from "../Select/index.js";
+import { UnstyledToolbar } from "../UnstyledToolbar/index.js";
+
+type Props = {
+  numPages: number;
+  currentPage: number;
+  onPageChange: (newPage: number) => void;
+  pageSize: number;
+  pageSizeOptions: number[];
+  onPageSizeChange: (newPageSize: number) => void;
+  mkPageLink?: ((page: number) => string) | undefined;
+  className?: string | undefined;
+};
+
+export const Paginator = ({
+  numPages,
+  currentPage,
+  pageSize,
+  pageSizeOptions,
+  onPageChange,
+  onPageSizeChange,
+  mkPageLink,
+  className,
+}: Props) => (
+  <div className={clsx(styles.paginator, className)}>
+    <PageSizeSelect
+      pageSize={pageSize}
+      pageSizeOptions={pageSizeOptions}
+      onPageSizeChange={onPageSizeChange}
+    />
+    {numPages > 1 && (
+      <PaginatorToolbar
+        currentPage={currentPage}
+        numPages={numPages}
+        onPageChange={onPageChange}
+        mkPageLink={mkPageLink}
+      />
+    )}
+  </div>
+);
+
+type PageSizeSelectProps = {
+  pageSize: number;
+  pageSizeOptions: number[];
+  onPageSizeChange: (newPageSize: number) => void;
+};
+
+const PageSizeSelect = ({
+  pageSize,
+  onPageSizeChange,
+  pageSizeOptions,
+}: PageSizeSelectProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+
+  const onChange = useCallback(
+    (newPageSize: number) => {
+      startTransition(() => {
+        onPageSizeChange(newPageSize);
+      });
+    },
+    [startTransition, onPageSizeChange],
+  );
+
+  return (
+    <div className={styles.pageSizeSelect}>
+      <Select
+        label="Page size"
+        hideLabel
+        options={pageSizeOptions}
+        selectedKey={pageSize}
+        onSelectionChange={onChange}
+        show={(value) => `${value.toString()} per page`}
+        variant="ghost"
+        size="sm"
+      />
+      <CircleNotch
+        className={clsx(styles.loadingIndicator, {
+          [styles.visible ?? ""]: isTransitioning,
+        })}
+      />
+    </div>
+  );
+};
+
+type PaginatorProps = {
+  numPages: number;
+  currentPage: number;
+  onPageChange: (newPage: number) => void;
+  mkPageLink: ((page: number) => string) | undefined;
+};
+
+const PaginatorToolbar = ({
+  numPages,
+  currentPage,
+  onPageChange,
+  mkPageLink,
+}: PaginatorProps) => {
+  const first = useMemo(
+    () =>
+      currentPage <= 3 || numPages <= 5
+        ? 1
+        : currentPage - 2 - Math.max(2 - (numPages - currentPage), 0),
+    [currentPage, numPages],
+  );
+
+  const pages = useMemo(
+    () =>
+      Array.from({ length: Math.min(numPages - first + 1, 5) })
+        .fill(undefined)
+        .map((_, i) => i + first),
+    [numPages, first],
+  );
+
+  return (
+    <UnstyledToolbar
+      aria-label="Page"
+      className={styles.paginatorToolbar ?? ""}
+    >
+      <PageSelector
+        hideText
+        // I'm not quite sure why this is triggering, I'll need to figure this
+        // out later.  Something in Phosphor's types is incorrect and is making
+        // eslint think this icon is an error object somehow...
+        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+        beforeIcon={CaretLeft}
+        isDisabled={currentPage === 1}
+        page={1}
+        onPageChange={onPageChange}
+        mkPageLink={mkPageLink}
+      >
+        First Page
+      </PageSelector>
+      {pages.map((page) => {
+        return page === currentPage ? (
+          <SelectedPage key={page}>{page.toString()}</SelectedPage>
+        ) : (
+          <PageSelector
+            key={page}
+            page={page}
+            aria-label={`Page ${page.toString()}`}
+            onPageChange={onPageChange}
+            mkPageLink={mkPageLink}
+          >
+            {page.toString()}
+          </PageSelector>
+        );
+      })}
+      <PageSelector
+        hideText
+        // I'm not quite sure why this is triggering, I'll need to figure this
+        // out later.  Something in Phosphor's types is incorrect and is making
+        // eslint think this icon is an error object somehow...
+        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+        beforeIcon={CaretRight}
+        isDisabled={currentPage === numPages}
+        page={numPages}
+        onPageChange={onPageChange}
+        mkPageLink={mkPageLink}
+      >
+        Last Page
+      </PageSelector>
+    </UnstyledToolbar>
+  );
+};
+
+type PageSelectorProps = Pick<
+  ComponentProps<typeof ButtonLink>,
+  "hideText" | "beforeIcon" | "isDisabled" | "children"
+> & {
+  page: number;
+  onPageChange: (newPage: number) => void;
+  mkPageLink: ((page: number) => string) | undefined;
+};
+
+const PageSelector = ({ mkPageLink, ...props }: PageSelectorProps) =>
+  mkPageLink ? (
+    <PageLink mkPageLink={mkPageLink} {...props} />
+  ) : (
+    <PageButton {...props} />
+  );
+
+type PageLinkProps = Omit<
+  ComponentProps<typeof ButtonLink>,
+  "variant" | "size" | "href" | "onPress"
+> & {
+  page: number;
+  onPageChange: (newPage: number) => void;
+  mkPageLink: (page: number) => string;
+};
+
+const PageLink = ({
+  page,
+  isDisabled,
+  onPageChange,
+  mkPageLink,
+  ...props
+}: PageLinkProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+
+  const url = useMemo(() => mkPageLink(page), [page, mkPageLink]);
+  const onPress = useCallback(() => {
+    startTransition(() => {
+      onPageChange(page);
+    });
+  }, [onPageChange, page]);
+
+  return (
+    <ButtonLink
+      variant="ghost"
+      size="sm"
+      onPress={onPress}
+      href={url}
+      isDisabled={isDisabled === true || isTransitioning}
+      {...props}
+    />
+  );
+};
+
+type PageButtonProps = Omit<
+  ComponentProps<typeof Button>,
+  "variant" | "size" | "href" | "onPress"
+> & {
+  page: number;
+  onPageChange: (newPage: number) => void;
+};
+
+const PageButton = ({
+  page,
+  isDisabled,
+  onPageChange,
+  ...props
+}: PageButtonProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+
+  const onPress = useCallback(() => {
+    startTransition(() => {
+      onPageChange(page);
+    });
+  }, [onPageChange, page]);
+
+  return (
+    <Button
+      variant="ghost"
+      size="sm"
+      onPress={onPress}
+      isDisabled={isDisabled === true || isTransitioning}
+      {...props}
+    />
+  );
+};
+
+const SelectedPage = ({ children }: { children: string }) => (
+  <div
+    className={clsx(buttonStyles.button, styles.selectedPage)}
+    data-size="sm"
+    data-variant="ghost"
+    data-pressed
+    key={children}
+  >
+    <span className={buttonStyles.text}>{children}</span>
+  </div>
+);

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

@@ -0,0 +1,150 @@
+@use "sass:math";
+
+@use "../theme.scss";
+
+.searchInput {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(2);
+  display: inline-block;
+  position: relative;
+  display: inline-block;
+  width: calc(theme.spacing(1) * var(--width));
+  color: theme.color("button", "outline", "foreground");
+
+  .input {
+    display: inline-block;
+    width: 100%;
+    height: 100%;
+    border: 1px solid theme.color("border");
+    background-color: theme.color("background", "primary");
+    outline: none;
+    transition-property: border-color, box-shadow, background-color, color;
+    transition-duration: 100ms;
+    transition-timing-function: linear;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    caret-color: theme.color("forms", "input", "focus", "border");
+
+    &::-webkit-search-cancel-button {
+      display: none;
+    }
+
+    &[data-hovered] {
+      border-color: theme.color("forms", "input", "hover", "border");
+    }
+
+    &[data-focused] {
+      border-color: theme.color("forms", "input", "focus", "border");
+      box-shadow: 0px 0px 0px 4px theme.color("forms", "focus-color");
+    }
+
+    :placeholder {
+      color: theme.color("button", "disabled", "foreground");
+    }
+  }
+
+  .clearButton {
+    background: transparent;
+    border: none;
+    opacity: 1;
+    display: inline;
+    cursor: pointer;
+  }
+
+  .loadingIcon {
+    opacity: 0;
+    @include theme.spin;
+  }
+
+  .searchIcon,
+  .loadingIcon {
+    pointer-events: none;
+  }
+
+  .searchIcon,
+  .loadingIcon,
+  .clearButton {
+    position: absolute;
+    transition-property: opacity, color;
+    transition-duration: 100ms;
+    transition-timing-function: linear;
+  }
+
+  &[data-pending] {
+    .loadingIcon {
+      opacity: 1;
+    }
+
+    .searchIcon {
+      opacity: 0;
+    }
+  }
+
+  &[data-empty] {
+    .clearButton {
+      opacity: 0;
+      pointer-events: none;
+    }
+  }
+
+  @each $size, $values in theme.$button-sizes {
+    &[data-size="#{$size}"] {
+      height: theme.map-get-strict($values, "height");
+
+      .input {
+        border-radius: theme.map-get-strict($values, "border-radius");
+        font-size: theme.map-get-strict($values, "font-size");
+        padding: 0
+          theme.map-get-strict($values, "padding") +
+          theme.map-get-strict($values, "icon-size") +
+          theme.map-get-strict($values, "gap");
+        line-height: calc(theme.map-get-strict($values, "height") - 2px);
+      }
+
+      .searchIcon,
+      .loadingIcon,
+      .clearButton {
+        inset: math.div(
+            theme.map-get-strict($values, "height") - theme.map-get-strict(
+                $values,
+                "icon-size"
+              ),
+            2
+          )
+          auto;
+      }
+
+      .searchIcon,
+      .loadingIcon,
+      .clearIcon {
+        width: theme.map-get-strict($values, "icon-size");
+        height: theme.map-get-strict($values, "icon-size");
+      }
+
+      .searchIcon,
+      .loadingIcon {
+        left: theme.map-get-strict($values, "padding");
+      }
+
+      .clearButton {
+        right: theme.map-get-strict($values, "padding");
+      }
+    }
+  }
+
+  &[data-disabled] {
+    color: theme.color("button", "disabled", "foreground");
+
+    .input {
+      cursor: not-allowed;
+      background-color: theme.color("button", "disabled", "background");
+      border-color: transparent;
+    }
+
+    .clearButton {
+      pointer-events: none;
+    }
+  }
+}

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

@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { SearchInput as SearchInputComponent, SIZES } from "./index.js";
+
+const meta = {
+  component: SearchInputComponent,
+  argTypes: {
+    label: {
+      table: {
+        disable: true,
+      },
+    },
+    size: {
+      control: "inline-radio",
+      options: SIZES,
+      table: {
+        category: "Size",
+      },
+    },
+    width: {
+      control: "number",
+      table: {
+        category: "Size",
+      },
+    },
+    isPending: {
+      control: "boolean",
+      table: {
+        category: "State",
+      },
+    },
+    isDisabled: {
+      control: "boolean",
+      table: {
+        category: "State",
+      },
+    },
+  },
+} satisfies Meta<typeof SearchInputComponent>;
+export default meta;
+
+export const SearchInput = {
+  args: {
+    size: "md",
+    width: 60,
+    isPending: false,
+    isDisabled: false,
+  },
+} satisfies StoryObj<typeof SearchInputComponent>;

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

@@ -0,0 +1,43 @@
+import { CircleNotch } from "@phosphor-icons/react/dist/ssr/CircleNotch";
+import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
+import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import clsx from "clsx";
+import { type CSSProperties, type ComponentProps } from "react";
+import { Input, SearchField } from "react-aria-components";
+
+import styles from "./index.module.scss";
+import { UnstyledButton } from "../UnstyledButton/index.js";
+
+export const SIZES = ["xs", "sm", "md", "lg"] as const;
+
+type Props = ComponentProps<typeof SearchField> & {
+  label?: string | undefined;
+  size?: (typeof SIZES)[number] | undefined;
+  width: number;
+  isPending?: boolean | undefined;
+};
+
+export const SearchInput = ({
+  label,
+  size = "md",
+  width,
+  className,
+  isPending,
+  ...props
+}: Props) => (
+  <SearchField
+    aria-label={label ?? "Search"}
+    className={clsx(styles.searchInput, className)}
+    style={{ "--width": width } as CSSProperties}
+    data-size={size}
+    {...(isPending && { "data-pending": "" })}
+    {...props}
+  >
+    <Input className={styles.input ?? ""} placeholder="Search" />
+    <MagnifyingGlass className={styles.searchIcon} />
+    <CircleNotch className={styles.loadingIcon} />
+    <UnstyledButton className={styles.clearButton ?? ""}>
+      <XCircle weight="fill" className={styles.clearIcon} />
+    </UnstyledButton>
+  </SearchField>
+);

+ 83 - 0
packages/component-library/src/Select/index.module.scss

@@ -0,0 +1,83 @@
+@use "../theme.scss";
+
+.select {
+  .caret {
+    transition-property: transform;
+    transition-duration: 300ms;
+    transition-timing-function: ease;
+  }
+
+  &[data-open] {
+    .caret {
+      transform: rotate(-180deg);
+    }
+  }
+
+  &[data-label-hidden] .label {
+    @include theme.sr-only;
+  }
+}
+
+.popover {
+  // data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out"
+
+  .listbox {
+    min-width: var(--trigger-width);
+    background-color: theme.color("background", "modal");
+    border-radius: theme.border-radius("lg");
+    border: 1px solid theme.color("border");
+    color: theme.color("paragraph");
+    padding: theme.spacing(1);
+    display: flex;
+    flex-flow: column nowrap;
+    font-size: theme.font-size("sm");
+    box-shadow:
+      0px 4px 6px -4px rgba(0, 0, 0, 0.1),
+      0px 10px 15px -3px rgba(0, 0, 0, 0.1);
+    // origin-top-right"
+
+    .listboxItem {
+      padding: theme.spacing(2);
+      cursor: pointer;
+      background-color: transparent;
+      border-radius: theme.border-radius("lg");
+      outline: none;
+      white-space: nowrap;
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+      justify-content: space-between;
+      gap: theme.spacing(4);
+      transition-property: background-color;
+      transition-duration: 100ms;
+      transition-timing-function: linear;
+
+      .check {
+        width: theme.spacing(3);
+        height: theme.spacing(3);
+        color: theme.color("button", "primary", "background", "normal");
+        opacity: 0;
+        transition-property: opacity;
+        transition-duration: 100ms;
+        transition-timing-function: linear;
+      }
+
+      &[data-focused] {
+        background-color: theme.color(
+          "button",
+          "outline",
+          "background",
+          "hover"
+        );
+      }
+
+      &[data-focus-visible] {
+        box-shadow: 0px 0px 0px 4px theme.color("forms", "focus-color");
+      }
+
+      &[data-selected] .check {
+        opacity: 1;
+      }
+    }
+  }
+}

+ 95 - 0
packages/component-library/src/Select/index.stories.tsx

@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Select as SelectComponent } from "./index.js";
+import buttonMeta from "../Button/index.stories.js";
+
+const OPTIONS = ["foo", "bar", "baz"];
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const { children, beforeIcon, onPress, ...argTypes } = buttonMeta.argTypes;
+const meta = {
+  component: SelectComponent,
+  argTypes: {
+    ...argTypes,
+    icon: beforeIcon,
+    label: {
+      control: "text",
+      table: {
+        category: "Label",
+      },
+    },
+    hideLabel: {
+      control: "boolean",
+      table: {
+        category: "Label",
+      },
+    },
+    options: {
+      table: {
+        disable: true,
+      },
+    },
+    defaultSelectedKey: {
+      table: {
+        disable: true,
+      },
+    },
+    show: {
+      table: {
+        disable: true,
+      },
+    },
+    placement: {
+      control: "select",
+      options: [
+        "bottom",
+        "bottom left",
+        "bottom right",
+        "bottom start",
+        "bottom end",
+        "top",
+        "top left",
+        "top right",
+        "top start",
+        "top end",
+        "left",
+        "left top",
+        "left bottom",
+        "start",
+        "start top",
+        "start bottom",
+        "right",
+        "right top",
+        "right bottom",
+        "end",
+        "end top",
+        "end bottom",
+      ],
+      table: {
+        category: "Popover",
+      },
+    },
+    onSelectionChange: {
+      table: {
+        category: "Behavior",
+      },
+    },
+  },
+} satisfies Meta<typeof SelectComponent>;
+export default meta;
+
+export const Select = {
+  args: {
+    defaultSelectedKey: "foo",
+    options: OPTIONS,
+    variant: "primary",
+    size: "md",
+    isDisabled: false,
+    isPending: false,
+    rounded: false,
+    hideText: false,
+    show: (value) => `The option ${value.toString()}`,
+    label: "A Select!",
+    hideLabel: true,
+  },
+} satisfies StoryObj<typeof SelectComponent>;

+ 70 - 62
packages/component-library/src/Select/index.tsx

@@ -1,5 +1,6 @@
+import { Check } from "@phosphor-icons/react/dist/ssr/Check";
 import clsx from "clsx";
-import { type ComponentProps, type ReactNode, useCallback } from "react";
+import type { ComponentProps, ReactNode } from "react";
 import {
   type PopoverProps,
   Label,
@@ -7,87 +8,94 @@ import {
   Popover,
   ListBox,
   ListBoxItem,
+  SelectValue,
 } from "react-aria-components";
 
+import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
 
 type Props<T> = Omit<
   ComponentProps<typeof BaseSelect>,
-  "selectedKey" | "onSelectionChange"
-> & {
-  selectedKey: T;
-  onSelectionChange: (newValue: T) => void;
-  options: readonly T[];
-  show?: (value: T) => string;
-  variant?: ComponentProps<typeof Button>["variant"];
-  size?: ComponentProps<typeof Button>["size"];
-  rounded?: ComponentProps<typeof Button>["rounded"];
-  hideText?: ComponentProps<typeof Button>["hideText"];
-  beforeIcon?: ComponentProps<typeof Button>["beforeIcon"];
-  placement?: PopoverProps["placement"] | undefined;
-  label: ReactNode;
-  hideLabel?: boolean | undefined;
-};
+  "defaultSelectedKey" | "selectedKey" | "onSelectionChange"
+> &
+  Pick<
+    ComponentProps<typeof Button>,
+    "variant" | "size" | "rounded" | "hideText" | "isPending"
+  > &
+  Pick<PopoverProps, "placement"> & {
+    options: readonly T[];
+    show?: (value: T) => string;
+    icon?: ComponentProps<typeof Button>["beforeIcon"];
+    label: ReactNode;
+    hideLabel?: boolean | undefined;
+  } & (
+    | {
+        defaultSelectedKey: T;
+      }
+    | {
+        selectedKey: T;
+        onSelectionChange: (newValue: T) => void;
+      }
+  );
 
 export const Select = <T extends string | number>({
+  className,
   options,
   show,
-  selectedKey,
-  onSelectionChange,
   variant,
   size,
   rounded,
   hideText,
-  beforeIcon,
+  icon,
   label,
   hideLabel,
   placement,
+  isPending,
   ...props
-}: Props<T>) => {
-  const handleSelectionChange = useCallback(
-    (newKey: T) => {
-      if (newKey !== selectedKey) {
-        onSelectionChange(newKey);
-      }
-    },
-    [onSelectionChange, selectedKey],
-  );
-  return (
-    <BaseSelect
-      selectedKey={selectedKey}
-      // @ts-expect-error react-aria coerces everything to Key for some reason...
-      onSelectionChange={handleSelectionChange}
-      {...props}
+}: Props<T>) => (
+  // @ts-expect-error react-aria coerces everything to Key for some reason...
+  <BaseSelect
+    className={clsx(styles.select, className)}
+    data-label-hidden={hideLabel ? "" : undefined}
+    {...("selectedKey" in props && { selectedKey: props.selectedKey })}
+    {...props}
+  >
+    <Label className={styles.label}>{label}</Label>
+    <Button
+      afterIcon={({ className }) => (
+        <DropdownCaretDown className={clsx(styles.caret, className)} />
+      )}
+      variant={variant}
+      size={size}
+      rounded={rounded}
+      hideText={hideText}
+      beforeIcon={icon}
+      isPending={isPending === true}
     >
-      <Label className={clsx({ "sr-only": hideLabel })}>{label}</Label>
-      <Button
-        afterIcon={DropdownCaretDown}
-        variant={variant}
-        size={size}
-        rounded={rounded}
-        hideText={hideText}
-        beforeIcon={beforeIcon}
-      >
-        {show?.(selectedKey) ?? selectedKey.toString()}
-      </Button>
-      <Popover
-        {...(placement && { placement })}
-        className="min-w-[--trigger-width] bg-white data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out dark:bg-steel-950"
+      <SelectValue<{ id: T }>>
+        {({ selectedItem }) =>
+          selectedItem ? (show?.(selectedItem.id) ?? selectedItem.id) : <></>
+        }
+      </SelectValue>
+    </Button>
+    <Popover {...(placement && { placement })} className={styles.popover ?? ""}>
+      <ListBox
+        className={styles.listbox ?? ""}
+        items={options.map((id) => ({ id }))}
       >
-        <ListBox
-          className="bg-pythpurple-100 text-pythpurple-950 flex origin-top-right flex-col rounded border border-neutral-400 py-1 text-sm shadow shadow-neutral-400 outline-none"
-          items={options.map((id) => ({ id }))}
-        >
-          {({ id }) => (
-            <ListBoxItem className="cursor-pointer whitespace-nowrap px-2 py-1 text-xs outline-none data-[disabled]:cursor-default data-[selected]:cursor-default data-[focused]:bg-black/10 data-[selected]:data-[focused]:bg-transparent data-[selected]:font-bold dark:data-[focused]:bg-white/10">
-              {show?.(id) ?? id}
-            </ListBoxItem>
-          )}
-        </ListBox>
-      </Popover>
-    </BaseSelect>
-  );
-};
+        {({ id }) => (
+          <ListBoxItem
+            className={styles.listboxItem ?? ""}
+            textValue={show?.(id) ?? id.toString()}
+          >
+            <span>{show?.(id) ?? id}</span>
+            <Check weight="bold" className={styles.check} />
+          </ListBoxItem>
+        )}
+      </ListBox>
+    </Popover>
+  </BaseSelect>
+);
 
 const DropdownCaretDown = (
   props: Omit<ComponentProps<"svg">, "xmlns" | "viewBox" | "fill">,

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

@@ -0,0 +1,16 @@
+@use "../theme.scss";
+
+.skeleton {
+  border-radius: theme.border-radius("lg");
+  background: theme.color("button", "disabled", "background");
+  @include theme.pulse;
+
+  .skeletonInner {
+    display: inline flow-root;
+    width: calc(theme.spacing(1) * var(--skeleton-width));
+
+    .skeletonLabel {
+      @include theme.sr-only;
+    }
+  }
+}

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

@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Skeleton as SkeletonComponent } from "./index.js";
+
+const meta = {
+  component: SkeletonComponent,
+  argTypes: {
+    label: {
+      control: "text",
+      table: {
+        category: "Skeleton",
+      },
+    },
+    width: {
+      control: "number",
+      table: {
+        category: "Skeleton",
+      },
+    },
+  },
+} satisfies Meta<typeof SkeletonComponent>;
+export default meta;
+
+export const Skeleton = {
+  args: {
+    label: "Loading",
+    width: 20,
+  },
+} satisfies StoryObj<typeof SkeletonComponent>;

+ 15 - 6
packages/component-library/src/Skeleton/index.tsx

@@ -1,12 +1,21 @@
 import clsx from "clsx";
-import type { ComponentProps } from "react";
+import type { ComponentProps, CSSProperties } from "react";
 
-type Props = Omit<ComponentProps<"span">, "children">;
+import styles from "./index.module.scss";
 
-export const Skeleton = ({ className, ...props }: Props) => (
-  <span className="animate-pulse rounded-lg bg-stone-200 dark:bg-steel-800">
-    <span className={clsx("inline-block", className)} {...props}>
-      <span className="sr-only">Loading</span>
+type Props = Omit<ComponentProps<"span">, "children"> & {
+  width: number;
+  label?: string | undefined;
+};
+
+export const Skeleton = ({ className, label, width, ...props }: Props) => (
+  <span className={styles.skeleton}>
+    <span
+      style={{ "--skeleton-width": width } as CSSProperties}
+      className={clsx(styles.skeletonInner, className)}
+      {...props}
+    >
+      <span className={styles.skeletonLabel}>{label ?? "Loading"}</span>
     </span>
   </span>
 );

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

@@ -0,0 +1,143 @@
+@use "../theme.scss";
+
+.tableContainer {
+  background-color: theme.color("background", "primary");
+  border-radius: theme.border-radius("xl");
+  position: relative;
+
+  .loaderWrapper {
+    position: absolute;
+    top: theme.spacing(10);
+    left: 0;
+    right: 0;
+    height: theme.spacing(0.5);
+    overflow: hidden;
+
+    .loader {
+      width: 100%;
+      height: 100%;
+      background-color: theme.color("forms", "input", "focus", "border");
+      transform-origin: left;
+      animation: progress 1s infinite linear;
+
+      @keyframes progress {
+        0% {
+          transform: translateX(0) scaleX(0);
+        }
+        40% {
+          transform: translateX(0) scaleX(0.4);
+        }
+        100% {
+          transform: translateX(100%) scaleX(0.5);
+        }
+      }
+    }
+  }
+
+  .table {
+    border-collapse: collapse;
+  }
+
+  .tableHeader {
+    border-bottom: 1px solid theme.color("background", "secondary");
+    font-size: theme.font-size("xs");
+    line-height: theme.spacing(4);
+    color: theme.color("muted");
+
+    .cell {
+      font-weight: theme.font-weight("medium");
+      padding-top: theme.spacing(3);
+      padding-bottom: theme.spacing(3);
+    }
+  }
+
+  .tableBody {
+    font-size: theme.font-size("sm");
+
+    .row {
+      background-color: transparent;
+      transition-property: background-color;
+      transition-duration: 100ms;
+      transition-timing-function: linear;
+      outline: none;
+
+      &:last-child {
+        .cell {
+          &:first-child {
+            border-bottom-left-radius: theme.border-radius("xl");
+          }
+          &:last-child {
+            border-bottom-right-radius: theme.border-radius("xl");
+          }
+        }
+      }
+
+      &[data-hovered] {
+        background-color: theme.color(
+          "button",
+          "outline",
+          "background",
+          "hover"
+        );
+      }
+
+      &[data-pressed] {
+        background-color: theme.color(
+          "button",
+          "outline",
+          "background",
+          "active"
+        );
+      }
+
+      &[data-focus-visible] {
+        outline: theme.color("forms", "input", "focus", "border") auto 1px;
+      }
+
+      &[data-href] {
+        cursor: pointer;
+      }
+
+      .cell {
+        padding-top: theme.spacing(4);
+        padding-bottom: theme.spacing(4);
+      }
+    }
+  }
+
+  .cell {
+    padding-left: theme.spacing(2);
+    padding-right: theme.spacing(2);
+    white-space: nowrap;
+    border: 0;
+    outline: none;
+
+    &:first-child {
+      padding-left: theme.spacing(4);
+    }
+
+    &:last-child {
+      padding-right: theme.spacing(4);
+    }
+
+    &[data-alignment="left"] {
+      text-align: left;
+    }
+
+    &[data-alignment="center"] {
+      text-align: center;
+    }
+
+    &[data-alignment="right"] {
+      text-align: right;
+    }
+
+    &[data-fill] {
+      width: 100%;
+    }
+
+    &[data-focus-visible] {
+      outline: theme.color("forms", "input", "focus", "border") auto 1px;
+    }
+  }
+}

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

@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Table as TableComponent } from "./index.js";
+
+const meta = {
+  component: TableComponent,
+  argTypes: {
+    columns: {
+      table: {
+        disable: true,
+      },
+    },
+    rows: {
+      table: {
+        disable: true,
+      },
+    },
+    renderEmptyState: {
+      table: {
+        disable: true,
+      },
+    },
+    label: {
+      table: {
+        category: "Accessibility",
+      },
+    },
+    isUpdating: {
+      control: "boolean",
+      table: {
+        category: "State",
+      },
+    },
+    isLoading: {
+      control: "boolean",
+      table: {
+        category: "State",
+      },
+    },
+  },
+} satisfies Meta<typeof TableComponent>;
+export default meta;
+
+export const Table = {
+  args: {
+    label: "A Table",
+    isUpdating: false,
+    isLoading: false,
+    columns: [
+      {
+        name: "PRICE FEED",
+        id: "feed",
+        isRowHeader: true,
+        loadingSkeletonWidth: 16,
+      },
+      {
+        name: "PRICE",
+        id: "price",
+        fill: true,
+        loadingSkeletonWidth: 30,
+      },
+      {
+        name: "CONFIDENCE",
+        id: "confidence",
+        loadingSkeletonWidth: 20,
+        alignment: "right",
+      },
+    ],
+    rows: [
+      {
+        id: "BTC/USD",
+        href: "#",
+        data: {
+          feed: "BTC/USD",
+          price: "$100,000",
+          confidence: "+/- 5%",
+        },
+      },
+      {
+        id: "ETH/USD",
+        href: "#",
+        data: {
+          feed: "ETH/USD",
+          price: "$1,000",
+          confidence: "+/- 10%",
+        },
+      },
+      {
+        id: "SOL/USD",
+        href: "#",
+        data: {
+          feed: "SOL/USD",
+          price: "$1,000,000,000",
+          confidence: "+/- 0.1%",
+        },
+      },
+    ],
+  },
+} satisfies StoryObj<typeof TableComponent>;

+ 46 - 48
packages/component-library/src/Table/index.tsx

@@ -9,6 +9,8 @@ import type {
   TableBodyProps,
 } from "react-aria-components";
 
+import styles from "./index.module.scss";
+import { Skeleton } from "../Skeleton/index.js";
 import {
   UnstyledCell,
   UnstyledColumn,
@@ -23,14 +25,16 @@ type TableProps<T extends string> = {
   columns: ColumnConfig<T>[];
   rows: RowConfig<T>[];
   isLoading?: boolean | undefined;
+  isUpdating?: boolean | undefined;
   renderEmptyState?: TableBodyProps<T>["renderEmptyState"];
 };
 
-type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
+export type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
   name: ReactNode;
   id: T;
   fill?: boolean | undefined;
-  alignment?: Alignment;
+  alignment?: Alignment | undefined;
+  loadingSkeletonWidth?: number | undefined;
 };
 
 type Alignment = "left" | "center" | "right" | undefined;
@@ -48,6 +52,7 @@ export const Table = <T extends string>({
   rows,
   columns,
   isLoading,
+  isUpdating,
   renderEmptyState,
 }: TableProps<T>) => {
   const [debouncedRows, setDebouncedRows] = useState(rows);
@@ -61,57 +66,55 @@ export const Table = <T extends string>({
   );
 
   return (
-    <div className="relative">
-      {isLoading && (
-        <div
-          className={clsx(
-            "absolute left-0 right-0 top-8 z-10 h-0.5 overflow-hidden opacity-0 transition",
-            {
-              "opacity-100": true,
-            },
-          )}
-        >
-          <div className="size-full origin-left animate-progress bg-violet-500" />
+    <div className={styles.tableContainer}>
+      {isUpdating && (
+        <div className={styles.loaderWrapper}>
+          <div className={styles.loader} />
         </div>
       )}
-      <UnstyledTable aria-label={label}>
+      <UnstyledTable aria-label={label} className={styles.table ?? ""}>
         <UnstyledTableHeader
           columns={columns}
-          className="border-b border-stone-300 bg-beige-100 pb-4 text-xs text-stone-600 dark:border-steel-600 dark:bg-steel-900 dark:text-steel-400"
+          className={styles.tableHeader ?? ""}
         >
-          {(column: ColumnConfig<T>) => (
-            <UnstyledColumn
-              className={clsx(
-                "whitespace-nowrap pb-4 font-medium",
-                cellClassName(columns, column),
-              )}
-              {...column}
-            >
+          {({ fill, alignment, ...column }: ColumnConfig<T>) => (
+            <UnstyledColumn {...cellProps(alignment, fill)} {...column}>
               {column.name}
             </UnstyledColumn>
           )}
         </UnstyledTableHeader>
         <UnstyledTableBody
-          items={debouncedRows}
-          className="text-sm"
+          items={isLoading ? [] : debouncedRows}
+          className={styles.tableBody ?? ""}
           {...(renderEmptyState !== undefined && { renderEmptyState })}
         >
-          {({ className: rowClassName, data, ...row }: RowConfig<T>) => (
+          {isLoading ? (
             <UnstyledRow
-              className={clsx(
-                "h-16 transition-colors duration-100 data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 dark:data-[hovered]:bg-white/5 dark:data-[pressed]:bg-white/10",
-                { "cursor-pointer": "href" in row },
-                rowClassName,
-              )}
+              id="loading"
+              key="loading"
+              className={styles.row ?? ""}
               columns={columns}
-              {...row}
             >
-              {(column: ColumnConfig<T>) => (
-                <UnstyledCell className={cellClassName(columns, column)}>
-                  {data[column.id]}
+              {({ alignment, fill, loadingSkeletonWidth }: ColumnConfig<T>) => (
+                <UnstyledCell {...cellProps(alignment, fill)}>
+                  <Skeleton width={loadingSkeletonWidth ?? 10} />
                 </UnstyledCell>
               )}
             </UnstyledRow>
+          ) : (
+            ({ className: rowClassName, data, ...row }: RowConfig<T>) => (
+              <UnstyledRow
+                className={clsx(styles.row, rowClassName)}
+                columns={columns}
+                {...row}
+              >
+                {({ alignment, fill, id }: ColumnConfig<T>) => (
+                  <UnstyledCell {...cellProps(alignment, fill)}>
+                    {data[id]}
+                  </UnstyledCell>
+                )}
+              </UnstyledRow>
+            )
           )}
         </UnstyledTableBody>
       </UnstyledTable>
@@ -119,16 +122,11 @@ export const Table = <T extends string>({
   );
 };
 
-const cellClassName = <T extends string>(
-  columns: ColumnConfig<T>[],
-  column: ColumnConfig<T>,
-) =>
-  clsx("px-2", {
-    "pl-4": column === columns[0],
-    "pr-4": column === columns.at(-1),
-    "text-left": column.alignment === "left",
-    "text-right": column.alignment === "right",
-    "text-center":
-      column.alignment === "center" || column.alignment === undefined,
-    "w-full": column.fill,
-  });
+const cellProps = (
+  alignment: Alignment | undefined,
+  fill: boolean | undefined,
+) => ({
+  className: styles.cell ?? "",
+  "data-alignment": alignment ?? "left",
+  ...(fill && { "data-fill": "" }),
+});

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

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

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

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

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

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

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

@@ -0,0 +1,14 @@
+/**
+ * The react-aria components aren't marked as "use client" so it's a bit
+ * obnoxious to use them; this file just adds a client boundary and re-exports
+ * the react-aria components to avoid that problem.
+ */
+
+"use client";
+
+export {
+  Tab as UnstyledTab,
+  TabList as UnstyledTabList,
+  TabPanel as UnstyledTabPanel,
+  Tabs as UnstyledTabs,
+} from "react-aria-components";

+ 0 - 4
packages/component-library/src/index.ts

@@ -1,4 +0,0 @@
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-export const tailwindGlob = `${path.dirname(fileURLToPath(import.meta.url))}/**/*.{ts,tsx}`;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff