diff --git a/apps/minifront/src/components/root-router.tsx b/apps/minifront/src/components/root-router.tsx index 7fb689d21e..ad16580bf9 100644 --- a/apps/minifront/src/components/root-router.tsx +++ b/apps/minifront/src/components/root-router.tsx @@ -14,8 +14,10 @@ import { StakingLayout } from './staking/layout'; import { IbcLayout } from './ibc/layout'; import { abortLoader } from '../abort-loader'; import type { Router } from '@remix-run/router'; +import { routes as v2Routes } from './v2/root-router'; export const rootRouter: Router = createHashRouter([ + ...v2Routes, { path: '/', element: , diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx new file mode 100644 index 0000000000..874d8d8923 --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx @@ -0,0 +1,24 @@ +import { Button } from '@repo/ui/Button'; +import { Dialog } from '@repo/ui/Dialog'; +import { Text } from '@repo/ui/Text'; +import { Info } from 'lucide-react'; + +export const AssetsCardTitle = () => ( +
+ Asset Balances + + + + + + + Your balances are shielded, and are known only to you. They are not visible on chain. Each + Penumbra wallet controls many numbered accounts, each with its own balance. Account + information is never revealed on-chain. + + + +
+); diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-page/equivalent-values.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-page/equivalent-values.tsx new file mode 100644 index 0000000000..8a752ce786 --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-page/equivalent-values.tsx @@ -0,0 +1,23 @@ +import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { asValueView } from '@penumbra-zone/getters/equivalent-value'; +import { getDisplayDenomFromView, getEquivalentValues } from '@penumbra-zone/getters/value-view'; +import { ValueViewComponent } from '@repo/ui/ValueViewComponent'; + +export const EquivalentValues = ({ valueView }: { valueView?: ValueView }) => { + const equivalentValuesAsValueViews = (getEquivalentValues.optional()(valueView) ?? []).map( + asValueView, + ); + + return ( +
+ {equivalentValuesAsValueViews.map(equivalentValueAsValueView => ( + + ))} +
+ ); +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx new file mode 100644 index 0000000000..e9f3122cad --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx @@ -0,0 +1,76 @@ +import { Density } from '@repo/ui/Density'; +import { Table } from '@repo/ui/Table'; +import { BalancesByAccount, groupByAccount, useBalancesResponses } from '../../../../state/shared'; +import { shouldDisplay } from '../../../../fetchers/balances/should-display'; +import { sortByPriorityScore } from '../../../../fetchers/balances/by-priority-score'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; +import { PagePath } from '../../../metadata/paths'; +import { getAddressIndex } from '@penumbra-zone/getters/address-view'; +import { AbridgedZQueryState } from '@penumbra-zone/zquery/src/types'; +import { ValueViewComponent } from '@repo/ui/ValueViewComponent'; +import { EquivalentValues } from './equivalent-values'; +import { TableTitle } from './table-title'; +import { Link } from 'react-router-dom'; +import { Button } from '@repo/ui/Button'; +import { ArrowRightLeft } from 'lucide-react'; + +const getTradeLink = (balance: BalancesResponse): string => { + const metadata = getMetadataFromBalancesResponseOptional(balance); + const accountIndex = getAddressIndex(balance.accountAddress).account; + const accountQuery = accountIndex ? `&account=${accountIndex}` : ''; + return metadata ? `${PagePath.SWAP}?from=${metadata.symbol}${accountQuery}` : PagePath.SWAP; +}; + +const filteredBalancesByAccountSelector = ( + zQueryState: AbridgedZQueryState, +): BalancesByAccount[] => + zQueryState.data?.filter(shouldDisplay).sort(sortByPriorityScore).reduce(groupByAccount, []) ?? + []; + +const BUTTON_CELL_WIDTH_PX = '56px'; + +export const AssetsPage = () => { + const balancesByAccount = useBalancesResponses({ + select: filteredBalancesByAccountSelector, + shouldReselect: (before, after) => before?.data !== after.data, + }); + + return ( +
+ {balancesByAccount?.map(account => ( + }> + + + Asset + Estimate + + + + + {account.balances.map((balance, index) => ( + + + + + + + + + + + + + + + + + ))} + +
+ ))} +
+ ); +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-page/table-title.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-page/table-title.tsx new file mode 100644 index 0000000000..eb20d23318 --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-page/table-title.tsx @@ -0,0 +1,22 @@ +import { AddressViewComponent } from '@repo/ui/AddressViewComponent'; +import { BalancesByAccount } from '../../../../state/shared'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; +import { useMemo } from 'react'; + +export const TableTitle = ({ account }: { account: BalancesByAccount }) => { + const addressView = useMemo( + () => + new AddressView({ + addressView: { + case: 'decoded', + value: { + address: account.address, + index: { account: account.account }, + }, + }, + }), + [account.address, account.account], + ); + + return ; +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/index.tsx new file mode 100644 index 0000000000..fc6a5fea5e --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/index.tsx @@ -0,0 +1,49 @@ +import { Card } from '@repo/ui/Card'; +import { Outlet, useNavigate } from 'react-router-dom'; +import { Grid } from '@repo/ui/Grid'; +import { Tabs } from '@repo/ui/Tabs'; +import { usePagePath } from '../../../fetchers/page-path'; +import { PagePath } from '../../metadata/paths'; +import { AssetsCardTitle } from './assets-card-title'; +import { TransactionsCardTitle } from './transactions-card-title'; + +/** @todo: Remove this function and its uses after we switch to v2 layout */ +const v2PathPrefix = (path: string) => `/v2${path}`; + +const CARD_TITLE_BY_PATH = { + [v2PathPrefix(PagePath.DASHBOARD)]: , + [v2PathPrefix(PagePath.TRANSACTIONS)]: , +}; + +const TABS_OPTIONS = [ + { label: 'Assets', value: v2PathPrefix(PagePath.DASHBOARD) }, + { label: 'Transactions', value: v2PathPrefix(PagePath.TRANSACTIONS) }, +]; + +export const DashboardLayout = () => { + const pagePath = usePagePath(); + const navigate = useNavigate(); + + return ( + + + + + +
+ navigate(value)} + options={TABS_OPTIONS} + actionType='accent' + /> + + +
+
+
+ + + + ); +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx b/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx new file mode 100644 index 0000000000..616da931c5 --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx @@ -0,0 +1,23 @@ +import { Button } from '@repo/ui/Button'; +import { Dialog } from '@repo/ui/Dialog'; +import { Text } from '@repo/ui/Text'; +import { Info } from 'lucide-react'; + +export const TransactionsCardTitle = () => ( +
+ Transactions List + + + + + + + Your wallet scans shielded chain data locally and indexes all relevant transactions it + detects, both incoming and outgoing. + + + +
+); diff --git a/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx new file mode 100644 index 0000000000..8515c65c1a --- /dev/null +++ b/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx @@ -0,0 +1,52 @@ +import { Table } from '@repo/ui/Table'; +import { useSummaries } from '../../../../state/transactions'; +import { Text } from '@repo/ui/Text'; +import { Link } from 'react-router-dom'; +import { SquareArrowOutUpRight } from 'lucide-react'; +import { Button } from '@repo/ui/Button'; + +export const TransactionsPage = () => { + const summaries = useSummaries(); + + return ( + + + + Block Height + Description + Hash + + + + {summaries.data?.map(summary => ( + + + {summary.height} + + + {summary.description} + + +
+ + + {summary.hash} + + + + + +
+
+
+ ))} +
+
+ ); +}; diff --git a/apps/minifront/src/components/v2/layout.tsx b/apps/minifront/src/components/v2/layout.tsx new file mode 100644 index 0000000000..67373191dd --- /dev/null +++ b/apps/minifront/src/components/v2/layout.tsx @@ -0,0 +1,14 @@ +import { Display } from '@repo/ui/Display'; +import { HeadTag } from '../metadata/head-tag'; +import { Outlet } from 'react-router-dom'; +import { Toaster } from '@repo/ui/components/ui/toaster'; +import { SyncingDialog } from '../syncing-dialog'; + +export const Layout = () => ( + + + + + + +); diff --git a/apps/minifront/src/components/v2/root-router.tsx b/apps/minifront/src/components/v2/root-router.tsx new file mode 100644 index 0000000000..25191c38cb --- /dev/null +++ b/apps/minifront/src/components/v2/root-router.tsx @@ -0,0 +1,52 @@ +import { redirect, RouteObject } from 'react-router-dom'; +import { Layout } from './layout'; +import { abortLoader } from '../../abort-loader'; +import { PagePath } from '../metadata/paths'; +import { DashboardLayout } from './dashboard-layout'; +import { AssetsPage } from './dashboard-layout/assets-page'; +import { TransactionsPage } from './dashboard-layout/transactions-page'; + +/** @todo: Delete this helper once we switch over to the v2 layout. */ +const temporarilyPrefixPathsWithV2 = (routes: RouteObject[]): RouteObject[] => + routes.map(route => { + if (route.index) { + return route; + } + + return { + ...route, + path: `/v2${route.path === '/' ? '' : route.path}`, + ...(route.children ? { children: temporarilyPrefixPathsWithV2(route.children) } : {}), + }; + }); + +/** + * @todo: Once we switch over to the v2 layout, we need to: + * 1) pass these routes to `createHashRouter()` and export the returned router, + * like in `../root-router.tsx`. + * 2) remove the call to `temporarilyPrefixPathsWithV2()`. + */ +export const routes: RouteObject[] = temporarilyPrefixPathsWithV2([ + { + path: '/', + element: , + loader: abortLoader, + children: [ + { index: true, loader: () => redirect(`/v2${PagePath.DASHBOARD}`) }, + { + path: PagePath.DASHBOARD, + element: , + children: [ + { + index: true, + element: , + }, + { + path: PagePath.TRANSACTIONS, + element: , + }, + ], + }, + ], + }, +]); diff --git a/apps/minifront/src/fetchers/page-path.ts b/apps/minifront/src/fetchers/page-path.ts index d8c6b81eb6..1fc923ba94 100644 --- a/apps/minifront/src/fetchers/page-path.ts +++ b/apps/minifront/src/fetchers/page-path.ts @@ -13,6 +13,8 @@ export const usePagePath = () => { }; export const matchPagePath = (str: string): PagePath => { + /** @todo: Remove next line after we switch to v2 layout */ + str = str.replace('/v2', ''); const pathValues = Object.values(PagePath); if (pathValues.includes(str as PagePath)) { diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index a1734b6cd2..b6a9980e74 100644 --- a/packages/ui/src/Button/index.tsx +++ b/packages/ui/src/Button/index.tsx @@ -158,7 +158,15 @@ interface RegularProps { export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); -/** A component for all your button needs! */ +/** + * A component for all your button needs! + * + * See individual props for how to use ` - - - )} - + + + + + + + {title} + + - {children} + {showCloseButton && ( + + + + + + )} + + + {children} + + {buttonGroupProps && } + + + - {buttonGroupProps && } - + + + + + ); }; diff --git a/packages/ui/src/Display/index.stories.tsx b/packages/ui/src/Display/index.stories.tsx new file mode 100644 index 0000000000..d3a938c7fc --- /dev/null +++ b/packages/ui/src/Display/index.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Display } from '.'; +import styled from 'styled-components'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Display, + tags: ['autodocs'], + argTypes: { + children: { control: false }, + }, + decorators: [ + Story => ( + + + + ), + ], +}; +export default meta; + +type Story = StoryObj; + +const OuterWidthIndicator = styled.div` + border: 1px solid ${props => props.theme.color.base.white}; +`; + +const InnerWidthIndicator = styled.div` + background: ${props => props.theme.color.base.white}; + color: ${props => props.theme.color.base.black}; + padding: ${props => props.theme.spacing(2)}; +`; + +export const FullWidth: Story = { + args: { + children: ( + + + The white background that this text sits inside of represents the{' '} + inside width of the <Display />{' '} + component. The white border to the left and right of this white bar represent the{' '} + outside width of the <Display />{' '} + component. + + + You can resize your window to see how the margins at left and right change depending on + the size of the browser window. + + + To test <Display /> at full width, click the "Full + Width" item in the left sidebar, and try resizing your browser. + + + ), + }, +}; diff --git a/packages/ui/src/Display/index.tsx b/packages/ui/src/Display/index.tsx new file mode 100644 index 0000000000..1c82dcc497 --- /dev/null +++ b/packages/ui/src/Display/index.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; +import { media } from '../utils/media'; + +const Root = styled.section` + padding: 0 ${props => props.theme.spacing(4)}; + + ${props => media.desktop` + padding: 0 ${props.theme.spacing(8)}; + `} +`; + +const ContentsWrapper = styled.div` + max-width: 1600px; + margin: 0 auto; +`; + +export interface DisplayProps { + children?: ReactNode; +} + +/** + * Wrap your top-level component for a given page (usually a ``) in + * `` to adhere to PenumbraUI guidelines regarding maximum layouts + * widths, horizontal margins, etc. + * + * ```tsx + * + * + * Column one + * Column two + * + * + * ``` + */ +export const Display = ({ children }: DisplayProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/Grid/index.tsx b/packages/ui/src/Grid/index.tsx index 4d8c882456..049d470463 100644 --- a/packages/ui/src/Grid/index.tsx +++ b/packages/ui/src/Grid/index.tsx @@ -32,19 +32,19 @@ interface GridItemProps extends BaseGridProps { * The mobile grid layout can only be split in half, so you can only set a * grid item to 6 or 12 columns on mobile. */ - mobile?: 6 | 12; + mobile?: 0 | 6 | 12; /** * The number of columns this grid item should span on tablet. * * The tablet grid layout can only be split into six columns. */ - tablet?: 2 | 4 | 6 | 8 | 10 | 12; + tablet?: 0 | 2 | 4 | 6 | 8 | 10 | 12; /** The number of columns this grid item should span on desktop. */ - desktop?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + desktop?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; /** The number of columns this grid item should span on large screens. */ - lg?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + lg?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; /** The number of columns this grid item should span on XL screens. */ - xl?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + xl?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; } export type GridProps = PropsWithChildren; diff --git a/packages/ui/src/PenumbraUIProvider/theme.ts b/packages/ui/src/PenumbraUIProvider/theme.ts index d133e46340..ea1f6ba19b 100644 --- a/packages/ui/src/PenumbraUIProvider/theme.ts +++ b/packages/ui/src/PenumbraUIProvider/theme.ts @@ -1,3 +1,5 @@ +import { hexOpacity } from '../utils/hexOpacity'; + /** * Used for reference in the `theme` object below. Not intended to be used * directly by consumers, but rather as a semantic reference for building the @@ -95,11 +97,13 @@ const PALETTE = { 900: '#6B3F18', 950: '#201004', }, + base: { + black: '#000000', + white: '#ffffff', + transparent: 'transparent', + }, }; -const FIFTEEN_PERCENT_OPACITY_IN_HEX = '26'; -const EIGHTY_PERCENT_OPACITY_IN_HEX = 'cc'; - export const theme = { blur: { none: '0px', @@ -170,9 +174,9 @@ export const theme = { contrast: PALETTE.green['50'], }, base: { - black: '#000', - white: '#fff', - transparent: 'transparent', + black: PALETTE.base.black, + white: PALETTE.base.white, + transparent: PALETTE.base.transparent, }, text: { primary: PALETTE.neutral['50'], @@ -181,9 +185,9 @@ export const theme = { special: PALETTE.orange['400'], }, action: { - hoverOverlay: PALETTE.teal['400'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, - activeOverlay: PALETTE.neutral['950'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, - disabledOverlay: PALETTE.neutral['950'] + EIGHTY_PERCENT_OPACITY_IN_HEX, + hoverOverlay: PALETTE.teal['400'] + hexOpacity(0.15), + activeOverlay: PALETTE.neutral['950'] + hexOpacity(0.15), + disabledOverlay: PALETTE.neutral['950'] + hexOpacity(0.8), primaryFocusOutline: PALETTE.orange['400'], secondaryFocusOutline: PALETTE.teal['400'], unshieldFocusOutline: PALETTE.purple['400'], @@ -191,8 +195,12 @@ export const theme = { destructiveFocusOutline: PALETTE.red['400'], }, other: { - tonalStroke: PALETTE.neutral['50'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, + tonalStroke: PALETTE.neutral['50'] + hexOpacity(0.15), + tonalFill5: PALETTE.neutral['50'] + hexOpacity(0.05), + tonalFill10: PALETTE.neutral['50'] + hexOpacity(0.1), solidStroke: PALETTE.neutral['700'], + dialogBackground: PALETTE.teal['700'] + hexOpacity(0.1), + overlay: PALETTE.base.black + hexOpacity(0.5), }, }, font: { diff --git a/packages/ui/src/Table/index.tsx b/packages/ui/src/Table/index.tsx index 51108e2c9f..8285a19f09 100644 --- a/packages/ui/src/Table/index.tsx +++ b/packages/ui/src/Table/index.tsx @@ -8,12 +8,13 @@ import { ConditionalWrap } from '../utils/ConditionalWrap'; const FIVE_PERCENT_OPACITY_IN_HEX = '0d'; // So named to avoid naming conflicts with `` -const StyledTable = styled.table` +const StyledTable = styled.table<{ $layout?: 'fixed' | 'auto' }>` width: 100%; background-color: ${props => props.theme.color.neutral.contrast + FIVE_PERCENT_OPACITY_IN_HEX}; padding-left: ${props => props.theme.spacing(3)}; padding-right: ${props => props.theme.spacing(3)}; border-radius: ${props => props.theme.borderRadius.lg}; + table-layout: ${props => props.$layout ?? 'auto'}; `; const TitleAndTableWrapper = styled.div` @@ -29,6 +30,8 @@ export interface TableProps { /** Content that will appear above the table. */ title?: ReactNode; children: ReactNode; + /** Which CSS `table-layout` property to use. */ + layout?: 'fixed' | 'auto'; } /** @@ -72,7 +75,7 @@ export interface TableProps { *
* ``` */ -export const Table = ({ title, ...props }: TableProps) => ( +export const Table = ({ title, children, layout }: TableProps) => ( ( @@ -82,7 +85,9 @@ export const Table = ({ title, ...props }: TableProps) => ( )} > - + + {children} + ); @@ -107,6 +112,8 @@ interface CellStyledProps { } const cell = css` + box-sizing: border-box; + padding-left: ${props => props.theme.spacing(3)}; padding-right: ${props => props.theme.spacing(3)}; @@ -154,7 +161,6 @@ Table.Th = Th; const StyledTd = styled.td` border-bottom: 1px solid ${props => props.theme.color.other.tonalStroke}; color: ${props => props.theme.color.text.primary}; - ${props => props.$width && `width: ${props.$width};`} ${StyledTbody} > ${StyledTr}:last-child > & { border-bottom: none; diff --git a/packages/ui/src/ValueViewComponent/index.tsx b/packages/ui/src/ValueViewComponent/index.tsx index cde5cc36c5..5ff3a26283 100644 --- a/packages/ui/src/ValueViewComponent/index.tsx +++ b/packages/ui/src/ValueViewComponent/index.tsx @@ -11,21 +11,13 @@ import { useDensity } from '../hooks/useDensity'; type Context = 'default' | 'table'; -const Row = styled.span<{ $context: Context; $priority: 'primary' | 'secondary' }>` +const Row = styled.span` display: flex; gap: ${props => props.theme.spacing(2)}; align-items: center; - width: min-content; + width: max-content; max-width: 100%; text-overflow: ellipsis; - - ${props => - props.$context === 'table' && props.$priority === 'secondary' - ? ` - border-bottom: 2px dashed ${props.theme.color.other.tonalStroke}; - padding-bottom: ${props.theme.spacing(2)}; - ` - : ''}; `; const AssetIconWrapper = styled.div` @@ -37,7 +29,7 @@ const PillMarginOffsets = styled.div<{ $density: Density }>` margin-right: ${props => props.theme.spacing(props.$density === 'sparse' ? -1 : 0)}; `; -const Content = styled.div` +const Content = styled.div<{ $context: Context; $priority: 'primary' | 'secondary' }>` flex-grow: 1; flex-shrink: 1; @@ -46,6 +38,11 @@ const Content = styled.div` align-items: center; overflow: hidden; + + ${props => + props.$context === 'table' && + props.$priority === 'secondary' && + `border-bottom: 2px dashed ${props.theme.color.other.tonalStroke};`}; `; const SymbolWrapper = styled.div` @@ -58,7 +55,7 @@ const SymbolWrapper = styled.div` `; export interface ValueViewComponentProps { - valueView: ValueView; + valueView?: ValueView; /** * A `ValueViewComponent` will be rendered differently depending on which * context it's rendered in. By default, it'll be rendered in a pill. But in a @@ -86,6 +83,11 @@ export const ValueViewComponent = ( priority = 'primary', }: ValueViewComponentProps) => { const density = useDensity(); + + if (!valueView) { + return null; + } + const formattedAmount = getFormattedAmtFromValueView(valueView, true); const metadata = getMetadata.optional()(valueView); // Symbol default is "" and thus cannot do nullish coalescing @@ -101,12 +103,12 @@ export const ValueViewComponent = ( )} > - + - + {formattedAmount} {symbol} diff --git a/packages/ui/src/utils/hexOpacity.ts b/packages/ui/src/utils/hexOpacity.ts new file mode 100644 index 0000000000..d15c560a9b --- /dev/null +++ b/packages/ui/src/utils/hexOpacity.ts @@ -0,0 +1,12 @@ +/** + * Given a decimal opacity (between 0 and 1), returns a two-character string + * that can be appended to an RGB value for the alpha channel. + * + * ```ts + * `#000000${opacityInHex(0.5)}` // #00000080 -- i.e., black at 50% opacity + * ``` + */ +export const hexOpacity = (opacity: number) => + Math.round(opacity * 255) + .toString(16) + .padStart(2, '0');