diff --git a/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx b/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx index 6f07cf8eaf..ba8097256d 100644 --- a/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx +++ b/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx @@ -80,7 +80,14 @@ const AssetClassTable = ({ count: {count}, }, })), - [numFeedsByAssetClass, collator, closeDrawer, pathname, updateAssetClass], + [ + numFeedsByAssetClass, + collator, + closeDrawer, + pathname, + updateAssetClass, + updateSearch, + ], ); return ( , "value"> & { + symbolsToFeedKeys: Record; }; -type RecentlyAddedPriceFeed = { - id: string; - symbol: string; - priceFeedName: ReactNode; -}; +const YesterdaysPricesContext = createContext< + undefined | ReturnType>> +>(undefined); -export const FeaturedRecentlyAdded = ({ recentlyAdded }: Props) => { - const feedKeys = useMemo( - () => recentlyAdded.map(({ id }) => id), - [recentlyAdded], - ); - const symbols = useMemo( - () => recentlyAdded.map(({ symbol }) => symbol), - [recentlyAdded], - ); +export const YesterdaysPricesProvider = ({ + symbolsToFeedKeys, + ...props +}: Props) => { const state = useData( - ["yesterdaysPrices", feedKeys], - () => getYesterdaysPrices(symbols), + ["yesterdaysPrices", Object.values(symbolsToFeedKeys)], + () => getYesterdaysPrices(symbolsToFeedKeys), { refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL, }, ); - return ( - <> - {recentlyAdded.map(({ priceFeedName, id, symbol }, i) => ( - - -
- -
- - } - className={styles.recentlyAddedFeed ?? ""} - variant="tertiary" - /> - ))} - - ); + return ; }; const getYesterdaysPrices = async ( - symbols: string[], -): Promise> => { + symbolsToFeedKeys: Record, +): Promise> => { const url = new URL("/yesterdays-prices", window.location.origin); - for (const symbol of symbols) { + for (const symbol of Object.keys(symbolsToFeedKeys)) { url.searchParams.append("symbols", symbol); } const response = await fetch(url); const data: unknown = await response.json(); - return yesterdaysPricesSchema.parse(data); + return new Map( + Object.entries(yesterdaysPricesSchema.parse(data)).map( + ([symbol, value]) => [symbolsToFeedKeys[symbol] ?? "", value], + ), + ); }; const yesterdaysPricesSchema = z.record(z.string(), z.number()); +const useYesterdaysPrices = () => { + const state = use(YesterdaysPricesContext); + + if (state) { + return state; + } else { + throw new YesterdaysPricesNotInitializedError(); + } +}; + type ChangePercentProps = { - yesterdaysPriceState: ReturnType< - typeof useData>> - >; feedKey: string; - symbol: string; }; -const ChangePercent = ({ - yesterdaysPriceState, - feedKey, - symbol, -}: ChangePercentProps) => { +export const ChangePercent = ({ feedKey }: ChangePercentProps) => { + const yesterdaysPriceState = useYesterdaysPrices(); + switch (yesterdaysPriceState.type) { case StateType.Error: { // eslint-disable-next-line unicorn/no-null @@ -107,11 +83,16 @@ const ChangePercent = ({ case StateType.Loading: case StateType.NotLoaded: { - return ; + return ( + + ); } case StateType.Loaded: { - const yesterdaysPrice = yesterdaysPriceState.data[symbol]; + const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey); // eslint-disable-next-line unicorn/no-null return yesterdaysPrice === undefined ? null : ( @@ -132,7 +113,10 @@ const ChangePercentLoaded = ({ const currentPrice = useLivePrice(feedKey); return currentPrice === undefined ? ( - + ) : ( + {numberFormatter.format( (100 * Math.abs(currentPrice - priorPrice)) / currentPrice, @@ -173,3 +157,12 @@ const getDirection = (currentPrice: number, priorPrice: number) => { return "flat"; } }; + +class YesterdaysPricesNotInitializedError extends Error { + constructor() { + super( + "This component must be contained within a ", + ); + this.name = "YesterdaysPricesNotInitializedError"; + } +} diff --git a/apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss b/apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss deleted file mode 100644 index ad6932c1af..0000000000 --- a/apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -@use "@pythnetwork/component-library/theme"; - -.recentlyAddedFeed .footer { - display: flex; - flex-flow: row nowrap; - justify-content: space-between; - align-items: center; - color: theme.color("heading"); - font-weight: theme.font-weight("medium"); - line-height: theme.spacing(6); - font-size: theme.font-size("base"); - padding: 0 theme.spacing(2); - - .changePercent { - font-size: theme.font-size("sm"); - - .price { - transition: color 100ms linear; - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(1); - align-items: center; - - .caret { - width: theme.spacing(3); - height: theme.spacing(3); - transition: transform 300ms linear; - } - - &[data-direction="up"] { - color: theme.color("states", "success", "base"); - } - - &[data-direction="down"] { - color: theme.color("states", "error", "base"); - - .caret { - transform: rotate3d(1, 0, 0, 180deg); - } - } - } - } -} diff --git a/apps/insights/src/components/PriceFeeds/index.module.scss b/apps/insights/src/components/PriceFeeds/index.module.scss index 3928453ced..45cde5c6f6 100644 --- a/apps/insights/src/components/PriceFeeds/index.module.scss +++ b/apps/insights/src/components/PriceFeeds/index.module.scss @@ -1,55 +1,16 @@ @use "@pythnetwork/component-library/theme"; -.priceFeeds { - @include theme.max-width; - - .header { - @include theme.h3; - - color: theme.color("heading"); - font-weight: theme.font-weight("semibold"); - margin: theme.spacing(6) 0; - } - - .body { - display: flex; - flex-flow: column nowrap; - gap: theme.spacing(6); - - .featuredFeeds { - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(1); - - & > * { - flex: 1; - } - } - - .stats { - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(4); - align-items: center; - - & > * { - flex: 1; - } - } - - .priceFeedId { - color: theme.color("link", "normal"); - font-weight: theme.font-weight("medium"); - } - } -} - .priceFeedNameAndIcon, .priceFeedNameAndDescription { display: flex; flex-flow: row nowrap; gap: theme.spacing(3); align-items: center; + width: 100%; + + .priceFeedIcon { + flex: none; + } .priceFeedName { display: flex; @@ -73,31 +34,112 @@ } } -.priceFeedNameAndIcon .priceFeedIcon { - width: theme.spacing(6); - height: theme.spacing(6); +.priceFeedNameAndIcon { + .priceFeedIcon { + width: theme.spacing(6); + height: theme.spacing(6); - &.skeleton { - border-radius: theme.border-radius("full"); + &.skeleton { + border-radius: theme.border-radius("full"); + } + } + + .priceFeedName { + flex-grow: 1; + flex-basis: 0; } } -.priceFeedNameAndDescription { - .priceFeedIcon { - width: theme.spacing(10); - height: theme.spacing(10); +.priceFeeds { + @include theme.max-width; + + .header { + @include theme.h3; + + color: theme.color("heading"); + font-weight: theme.font-weight("semibold"); + margin: theme.spacing(6) 0; } - .nameAndDescription { + .body { display: flex; flex-flow: column nowrap; - gap: theme.spacing(1); + gap: theme.spacing(6); - .description { - font-size: theme.font-size("xs"); + .featuredFeeds, + .stats { + display: flex; + flex-flow: row nowrap; + align-items: center; + + & > * { + flex: 1 1 0px; + width: 0; + } + } + + .stats { + gap: theme.spacing(4); + } + + .featuredFeeds { + gap: theme.spacing(1); + + .feedCardContents { + display: flex; + flex-flow: column nowrap; + justify-content: space-between; + align-items: stretch; + padding: theme.spacing(3); + gap: theme.spacing(6); + + .priceFeedNameAndDescription { + .priceFeedIcon { + width: theme.spacing(10); + height: theme.spacing(10); + } + + .nameAndDescription { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + flex-grow: 1; + flex-basis: 0; + white-space: nowrap; + overflow: hidden; + + .priceFeedName { + overflow: hidden; + text-overflow: ellipsis; + } + + .description { + font-size: theme.font-size("xs"); + font-weight: theme.font-weight("medium"); + line-height: theme.spacing(4); + color: theme.color("muted"); + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .prices { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + color: theme.color("heading"); + font-weight: theme.font-weight("medium"); + line-height: 1; + font-size: theme.font-size("base"); + } + } + } + + .priceFeedId { + color: theme.color("link", "normal"); font-weight: theme.font-weight("medium"); - line-height: theme.spacing(4); - color: theme.color("muted"); } } } diff --git a/apps/insights/src/components/PriceFeeds/index.tsx b/apps/insights/src/components/PriceFeeds/index.tsx index d86f453688..4821116778 100644 --- a/apps/insights/src/components/PriceFeeds/index.tsx +++ b/apps/insights/src/components/PriceFeeds/index.tsx @@ -1,26 +1,33 @@ +import { ArrowLineDown } from "@phosphor-icons/react/dist/ssr/ArrowLineDown"; +import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut"; import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown"; +import { Info } from "@phosphor-icons/react/dist/ssr/Info"; import { StackPlus } from "@phosphor-icons/react/dist/ssr/StackPlus"; import { Badge } from "@pythnetwork/component-library/Badge"; import { Button } from "@pythnetwork/component-library/Button"; -import { Card } from "@pythnetwork/component-library/Card"; +import { + type Props as CardProps, + Card, +} from "@pythnetwork/component-library/Card"; import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer"; import { Skeleton } from "@pythnetwork/component-library/Skeleton"; import { StatCard } from "@pythnetwork/component-library/StatCard"; import base58 from "bs58"; import clsx from "clsx"; import Generic from "cryptocurrency-icons/svg/color/generic.svg"; -import { Fragment } from "react"; +import { Fragment, type ElementType } from "react"; import { z } from "zod"; import { AssetClassesDrawer } from "./asset-classes-drawer"; +import { YesterdaysPricesProvider, ChangePercent } from "./change-percent"; import { ComingSoonList } from "./coming-soon-list"; -import { FeaturedRecentlyAdded } from "./featured-recently-added"; import styles from "./index.module.scss"; import { PriceFeedsCard } from "./price-feeds-card"; import { getIcon } from "../../icons"; import { client } from "../../services/pyth"; import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds"; import { CopyButton } from "../CopyButton"; +import { LivePrice } from "../LivePrices"; const PRICE_FEEDS_ANCHOR = "priceFeeds"; @@ -37,6 +44,10 @@ export const PriceFeeds = async () => { !priceFeedsStaticConfig.featuredComingSoon.includes(symbol), ), ].slice(0, 5); + const featuredRecentlyAdded = filterFeeds( + priceFeeds.activeFeeds, + priceFeedsStaticConfig.featuredRecentlyAdded, + ); return (
@@ -48,6 +59,7 @@ export const PriceFeeds = async () => { header="Active Feeds" stat={priceFeeds.activeFeeds.length} href={`#${PRICE_FEEDS_ANCHOR}`} + corner={} /> { stat={priceFeedsStaticConfig.activeChains} href="https://docs.pyth.network/price-feeds/contract-addresses" target="_blank" + corner={} /> } />
- }> -
- ({ - id: product.price_account, - symbol, - priceFeedName: ( - - {product.display_symbol} - - ), - }))} - /> -
-
- [ + symbol, + product.price_account, + ]), + )} + > + } + feeds={featuredRecentlyAdded} + showPrices + linkFeeds + /> +
+ } + feeds={featuredComingSoon} toolbar={ + }> +

+ Each Price Feed Component that a Publisher{" "} + provides has an associated Score, which is determined + by that component{"'"}s Uptime,{" "} + Price Deviation, and Staleness. The publisher + {"'"}s Median Score measures the 50th percentile of + the Score across all of that publisher{"'"}s{" "} + Price Feed Components. The{" "} + Average Median Score is the average of the{" "} + Median Scores of all publishers who contribute to the + Pyth Network. +

+ + Learn more + +
+ + } stat={( publishers.reduce( (sum, publisher) => sum + publisher.medianScore, diff --git a/packages/app-logger/src/index.tsx b/packages/app-logger/src/index.tsx index 105df6c18f..804b3509cf 100644 --- a/packages/app-logger/src/index.tsx +++ b/packages/app-logger/src/index.tsx @@ -7,11 +7,13 @@ export const useLogger = () => { if (logger) { return logger; } else { - throw new NotInitializedError(); + throw new LoggerNotInitializedError(); } }; -class NotInitializedError extends Error { - override message = - "This component must be contained within a `LoggerProvider`!"; +class LoggerNotInitializedError extends Error { + constructor() { + super("This component must be contained within a "); + this.name = "LoggerNotInitializedError"; + } } diff --git a/packages/component-library/.storybook/preview.tsx b/packages/component-library/.storybook/preview.tsx index 5e4e2298f3..3e17d83e12 100644 --- a/packages/component-library/.storybook/preview.tsx +++ b/packages/component-library/.storybook/preview.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import "../src/Html/base.scss"; import styles from "./storybook.module.scss"; +import { OverlayVisibleContextProvider } from "../src/overlay-visible-context.js"; const preview = { parameters: { @@ -28,6 +29,11 @@ const preview = { export default preview; export const decorators: Decorator[] = [ + (Story) => ( + + + + ), withThemeByClassName({ themes: { Light: clsx(sans.className, styles.light), diff --git a/packages/component-library/src/Alert/index.module.scss b/packages/component-library/src/Alert/index.module.scss new file mode 100644 index 0000000000..7ec4520637 --- /dev/null +++ b/packages/component-library/src/Alert/index.module.scss @@ -0,0 +1,63 @@ +@use "../theme"; + +.modalOverlay { + position: fixed; + inset: 0; + z-index: 1; + + .modal { + position: fixed; + bottom: theme.spacing(8); + right: theme.spacing(8); + outline: none; + + .dialog { + background: theme.color("states", "info", "background-opaque"); + border-radius: theme.border-radius("3xl"); + backdrop-filter: blur(32px); + width: theme.spacing(156); + outline: none; + position: relative; + padding: theme.spacing(6); + padding-right: theme.spacing(16); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + + .closeButton { + position: absolute; + right: theme.spacing(2); + top: theme.spacing(2); + } + + .title { + @include theme.h4; + + display: flex; + flex-flow: row nowrap; + gap: theme.spacing(3); + align-items: center; + color: theme.color("heading"); + line-height: 1; + + .icon { + color: theme.color("states", "info", "normal"); + flex: none; + display: grid; + place-content: center; + font-size: theme.spacing(6); + } + } + + .body { + color: theme.color("paragraph"); + font-size: theme.font-size("sm"); + line-height: 140%; + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + align-items: flex-start; + } + } + } +} diff --git a/packages/component-library/src/Alert/index.stories.tsx b/packages/component-library/src/Alert/index.stories.tsx new file mode 100644 index 0000000000..5f82370ecc --- /dev/null +++ b/packages/component-library/src/Alert/index.stories.tsx @@ -0,0 +1,50 @@ +import * as Icon from "@phosphor-icons/react/dist/ssr"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Alert as AlertComponent, AlertTrigger } from "./index.js"; +import { Button } from "../Button/index.js"; + +const meta = { + component: AlertComponent, + decorators: [ + (Story) => ( + + + + + ), + ], + argTypes: { + icon: { + control: "select", + options: Object.keys(Icon), + mapping: Object.fromEntries( + Object.entries(Icon).map(([key, Icon]) => [key, ]), + ), + table: { + category: "Contents", + }, + }, + title: { + control: "text", + table: { + category: "Contents", + }, + }, + children: { + control: "text", + table: { + category: "Contents", + }, + }, + }, +} satisfies Meta; +export default meta; + +export const Alert = { + args: { + title: "An Alert", + children: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + }, +} satisfies StoryObj; diff --git a/packages/component-library/src/Alert/index.tsx b/packages/component-library/src/Alert/index.tsx new file mode 100644 index 0000000000..546eb6ee47 --- /dev/null +++ b/packages/component-library/src/Alert/index.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle"; +import clsx from "clsx"; +import type { ComponentProps, ReactNode } from "react"; +import { Dialog, Heading } from "react-aria-components"; + +import styles from "./index.module.scss"; +import { Button } from "../Button/index.js"; +import { Modal } from "../Modal/index.js"; + +export { DialogTrigger as AlertTrigger } from "react-aria-components"; + +const CLOSE_DURATION_IN_S = 0.1; +export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000; + +type OwnProps = Pick, "children"> & { + icon?: ReactNode | undefined; + title: ReactNode; +}; + +type Props = Omit, keyof OwnProps> & OwnProps; + +export const Alert = ({ + icon, + title, + children, + className, + ...props +}: Props) => ( + + {(state) => ( + + + + {icon &&
{icon}
} +
{title}
+
+
+ {typeof children === "function" ? children(state) : children} +
+
+ )} +
+); diff --git a/packages/component-library/src/Drawer/index.module.scss b/packages/component-library/src/Drawer/index.module.scss index ae70fc5dd4..6ba1686bdc 100644 --- a/packages/component-library/src/Drawer/index.module.scss +++ b/packages/component-library/src/Drawer/index.module.scss @@ -13,13 +13,13 @@ right: theme.spacing(4); width: 40%; max-width: theme.spacing(160); - background: theme.color("background", "primary"); - border: 1px solid theme.color("border"); - border-radius: theme.border-radius("3xl"); outline: none; - overflow: hidden; .dialog { + background: theme.color("background", "primary"); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("3xl"); + overflow: hidden; outline: none; display: flex; flex-flow: column nowrap; diff --git a/packages/component-library/src/Drawer/index.tsx b/packages/component-library/src/Drawer/index.tsx index 9afce5ebf7..c7f6149153 100644 --- a/packages/component-library/src/Drawer/index.tsx +++ b/packages/component-library/src/Drawer/index.tsx @@ -2,116 +2,64 @@ import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle"; import clsx from "clsx"; -import { motion, AnimatePresence } from "motion/react"; -import { - type ComponentProps, - type ReactNode, - type ContextType, - use, - useCallback, - useEffect, -} from "react"; -import { - Dialog, - Heading, - Modal as ModalComponent, - ModalOverlay as ModalOverlayComponent, - OverlayTriggerStateContext, -} from "react-aria-components"; +import type { ComponentProps, ReactNode } from "react"; +import { Dialog, Heading } from "react-aria-components"; import styles from "./index.module.scss"; import { Button } from "../Button/index.js"; -import { useSetOverlayVisible } from "../overlay-visible-context.js"; +import { Modal } from "../Modal/index.js"; export { DialogTrigger as DrawerTrigger } from "react-aria-components"; const CLOSE_DURATION_IN_S = 0.15; export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000; -// @ts-expect-error Looks like there's a typing mismatch currently between -// motion and react, probably due to us being on react 19. I'm expecting this -// will go away when react 19 is officially stabilized... -const ModalOverlay = motion.create(ModalOverlayComponent); -// @ts-expect-error Looks like there's a typing mismatch currently between -// motion and react, probably due to us being on react 19. I'm expecting this -// will go away when react 19 is officially stabilized... -const Modal = motion.create(ModalComponent); - -type OwnProps = { +type OwnProps = Pick, "children"> & { title: ReactNode; - children: - | ReactNode - | (( - state: NonNullable>, - ) => ReactNode); }; type Props = Omit, keyof OwnProps> & OwnProps; -export const Drawer = ({ title, children, className, ...props }: Props) => { - const state = use(OverlayTriggerStateContext); - const { hideOverlay, showOverlay } = useSetOverlayVisible(); - - useEffect(() => { - if (state?.isOpen) { - showOverlay(); - } - }, [state, showOverlay]); - - const onOpenChange = useCallback( - (newValue: boolean) => { - state?.setOpen(newValue); - }, - [state], - ); - - return ( - - {state?.isOpen && ( - - ( + + {(state) => ( + +
+ + {title} + + -
- {typeof children === "function" ? children(state) : children} -
-
-
- )} -
- ); -}; + Close + + + {typeof children === "function" ? children(state) : children} + + )} + +); diff --git a/packages/component-library/src/Modal/index.tsx b/packages/component-library/src/Modal/index.tsx new file mode 100644 index 0000000000..267a04d20d --- /dev/null +++ b/packages/component-library/src/Modal/index.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { motion, AnimatePresence } from "motion/react"; +import { + type ComponentProps, + type ContextType, + type ReactNode, + use, + useCallback, + useEffect, +} from "react"; +import { + Modal as ModalComponent, + ModalOverlay, + OverlayTriggerStateContext, +} from "react-aria-components"; + +import { useSetOverlayVisible } from "../overlay-visible-context.js"; + +// @ts-expect-error Looks like there's a typing mismatch currently between +// motion and react, probably due to us being on react 19. I'm expecting this +// will go away when react 19 is officially stabilized... +const MotionModal = motion.create(ModalComponent); + +// @ts-expect-error Looks like there's a typing mismatch currently between +// motion and react, probably due to us being on react 19. I'm expecting this +// will go away when react 19 is officially stabilized... +const MotionModalOverlay = motion.create(ModalOverlay); + +type OwnProps = { + overlayProps?: Omit< + ComponentProps, + "isOpen" | "isDismissable" | "onOpenChange" + >; + children: + | ReactNode + | (( + state: NonNullable>, + ) => ReactNode); +}; + +type Props = Omit, keyof OwnProps> & + OwnProps; + +export const Modal = ({ overlayProps, children, ...props }: Props) => { + const state = use(OverlayTriggerStateContext); + const { hideOverlay, showOverlay } = useSetOverlayVisible(); + + useEffect(() => { + if (state?.isOpen) { + showOverlay(); + } + }, [state, showOverlay]); + + const onOpenChange = useCallback( + (newValue: boolean) => { + state?.setOpen(newValue); + }, + [state], + ); + + return ( + + {state?.isOpen && ( + + + {typeof children === "function" ? children(state) : children} + + + )} + + ); +}; diff --git a/packages/component-library/src/StatCard/index.module.scss b/packages/component-library/src/StatCard/index.module.scss index e7b2c6735c..6dc24a4996 100644 --- a/packages/component-library/src/StatCard/index.module.scss +++ b/packages/component-library/src/StatCard/index.module.scss @@ -11,6 +11,13 @@ padding: theme.spacing(3); padding-bottom: theme.spacing(2); + .corner { + position: absolute; + right: theme.spacing(3); + top: theme.spacing(3); + display: flex; + } + .header { color: theme.color("muted"); text-align: left; diff --git a/packages/component-library/src/StatCard/index.stories.tsx b/packages/component-library/src/StatCard/index.stories.tsx index 967566e5a4..13871a5e91 100644 --- a/packages/component-library/src/StatCard/index.stories.tsx +++ b/packages/component-library/src/StatCard/index.stories.tsx @@ -31,6 +31,12 @@ const meta = { category: "Contents", }, }, + corner: { + control: "text", + table: { + category: "Contents", + }, + }, }, } satisfies Meta; export default meta; @@ -47,5 +53,6 @@ export const StatCard = { header: "Active Feeds", stat: "552", miniStat: "+5", + corner: ":)", }, } satisfies StoryObj; diff --git a/packages/component-library/src/StatCard/index.tsx b/packages/component-library/src/StatCard/index.tsx index d97f9d604c..4f3639b7b8 100644 --- a/packages/component-library/src/StatCard/index.tsx +++ b/packages/component-library/src/StatCard/index.tsx @@ -11,6 +11,7 @@ type Props = Omit< header: ReactNode; stat: ReactNode; miniStat?: ReactNode | undefined; + corner?: ReactNode | undefined; }; export const StatCard = ({ @@ -18,10 +19,12 @@ export const StatCard = ({ stat, miniStat, className, + corner, ...props }: Props) => (
+ {corner &&
{corner}
}

{header}

{stat}
diff --git a/packages/component-library/src/theme.scss b/packages/component-library/src/theme.scss index a5df474bb7..51ea440155 100644 --- a/packages/component-library/src/theme.scss +++ b/packages/component-library/src/theme.scss @@ -469,6 +469,11 @@ $color: ( light-dark(pallette-color("steel", 900), pallette-color("steel", 50)), ), "info": ( + "background-opaque": + light-dark( + rgb(from pallette-color("indigo", 200) r g b / 80%), + rgb(from pallette-color("indigo", 950) r g b / 80%) + ), "normal": light-dark(pallette-color("indigo", 600), pallette-color("indigo", 400)), ),