diff --git a/.eslintrc.js b/.eslintrc.js index d9e94ac198..c4284ac583 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -268,7 +268,7 @@ const typescriptRules = { zones: [ { target: ['ts/util', 'ts/types'], - from: ['ts/components', 'ts/axo'], + from: ['ts/components/**', 'ts/axo/**/*.dom.*'], message: 'Importing components is forbidden from ts/{util,types}', }, ], @@ -452,7 +452,7 @@ module.exports = { }, }, { - files: ['ts/axo/**/*.tsx'], + files: ['ts/axo/**/*.{ts,tsx}'], rules: { // Rule doesn't understand TypeScript namespaces 'no-inner-declarations': 'off', diff --git a/fixtures/badges/planet/planet-16-dark.svg b/fixtures/badges/planet/planet-16-dark.svg new file mode 100644 index 0000000000..2be31e12f5 --- /dev/null +++ b/fixtures/badges/planet/planet-16-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-16-light.svg b/fixtures/badges/planet/planet-16-light.svg new file mode 100644 index 0000000000..a97f4028c9 --- /dev/null +++ b/fixtures/badges/planet/planet-16-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-160.svg b/fixtures/badges/planet/planet-160.svg new file mode 100644 index 0000000000..b2fee5faee --- /dev/null +++ b/fixtures/badges/planet/planet-160.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-24-dark.svg b/fixtures/badges/planet/planet-24-dark.svg new file mode 100644 index 0000000000..5da16a1156 --- /dev/null +++ b/fixtures/badges/planet/planet-24-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-24-light.svg b/fixtures/badges/planet/planet-24-light.svg new file mode 100644 index 0000000000..f583645e3d --- /dev/null +++ b/fixtures/badges/planet/planet-24-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-36-dark.svg b/fixtures/badges/planet/planet-36-dark.svg new file mode 100644 index 0000000000..6b6db5a87e --- /dev/null +++ b/fixtures/badges/planet/planet-36-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/planet/planet-36-light.svg b/fixtures/badges/planet/planet-36-light.svg new file mode 100644 index 0000000000..ceae782206 --- /dev/null +++ b/fixtures/badges/planet/planet-36-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-16-dark.svg b/fixtures/badges/rocket/rocket-16-dark.svg new file mode 100644 index 0000000000..ef089b6705 --- /dev/null +++ b/fixtures/badges/rocket/rocket-16-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-16-light.svg b/fixtures/badges/rocket/rocket-16-light.svg new file mode 100644 index 0000000000..f8731b7d5a --- /dev/null +++ b/fixtures/badges/rocket/rocket-16-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-160.svg b/fixtures/badges/rocket/rocket-160.svg new file mode 100644 index 0000000000..275781af2d --- /dev/null +++ b/fixtures/badges/rocket/rocket-160.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-24-dark.svg b/fixtures/badges/rocket/rocket-24-dark.svg new file mode 100644 index 0000000000..fe2f61cf2a --- /dev/null +++ b/fixtures/badges/rocket/rocket-24-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-24-light.svg b/fixtures/badges/rocket/rocket-24-light.svg new file mode 100644 index 0000000000..6b326a295c --- /dev/null +++ b/fixtures/badges/rocket/rocket-24-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-36-dark.svg b/fixtures/badges/rocket/rocket-36-dark.svg new file mode 100644 index 0000000000..c5f1325bd4 --- /dev/null +++ b/fixtures/badges/rocket/rocket-36-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/rocket/rocket-36-light.svg b/fixtures/badges/rocket/rocket-36-light.svg new file mode 100644 index 0000000000..ab13c60bb7 --- /dev/null +++ b/fixtures/badges/rocket/rocket-36-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-16-dark.svg b/fixtures/badges/star/star-16-dark.svg new file mode 100644 index 0000000000..701eccc304 --- /dev/null +++ b/fixtures/badges/star/star-16-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-16-light.svg b/fixtures/badges/star/star-16-light.svg new file mode 100644 index 0000000000..2b2b5e6586 --- /dev/null +++ b/fixtures/badges/star/star-16-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-160.svg b/fixtures/badges/star/star-160.svg new file mode 100644 index 0000000000..1d9d4e4acb --- /dev/null +++ b/fixtures/badges/star/star-160.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-24-dark.svg b/fixtures/badges/star/star-24-dark.svg new file mode 100644 index 0000000000..3b1a13bbe3 --- /dev/null +++ b/fixtures/badges/star/star-24-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-24-light.svg b/fixtures/badges/star/star-24-light.svg new file mode 100644 index 0000000000..357fa29e6d --- /dev/null +++ b/fixtures/badges/star/star-24-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-36-dark.svg b/fixtures/badges/star/star-36-dark.svg new file mode 100644 index 0000000000..7bb13d0457 --- /dev/null +++ b/fixtures/badges/star/star-36-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/star/star-36-light.svg b/fixtures/badges/star/star-36-light.svg new file mode 100644 index 0000000000..d0ea4d3bd0 --- /dev/null +++ b/fixtures/badges/star/star-36-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-16-dark.svg b/fixtures/badges/sun/sun-16-dark.svg new file mode 100644 index 0000000000..53d097ef4c --- /dev/null +++ b/fixtures/badges/sun/sun-16-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-16-light.svg b/fixtures/badges/sun/sun-16-light.svg new file mode 100644 index 0000000000..8802f93957 --- /dev/null +++ b/fixtures/badges/sun/sun-16-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-160.svg b/fixtures/badges/sun/sun-160.svg new file mode 100644 index 0000000000..ca252d2a3f --- /dev/null +++ b/fixtures/badges/sun/sun-160.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-24-dark.svg b/fixtures/badges/sun/sun-24-dark.svg new file mode 100644 index 0000000000..4dd44da190 --- /dev/null +++ b/fixtures/badges/sun/sun-24-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-24-light.svg b/fixtures/badges/sun/sun-24-light.svg new file mode 100644 index 0000000000..012303a8aa --- /dev/null +++ b/fixtures/badges/sun/sun-24-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-36-dark.svg b/fixtures/badges/sun/sun-36-dark.svg new file mode 100644 index 0000000000..12f6836c39 --- /dev/null +++ b/fixtures/badges/sun/sun-36-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/sun/sun-36-light.svg b/fixtures/badges/sun/sun-36-light.svg new file mode 100644 index 0000000000..105fd634de --- /dev/null +++ b/fixtures/badges/sun/sun-36-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-16-dark.svg b/fixtures/badges/ufo/ufo-16-dark.svg new file mode 100644 index 0000000000..395b8b8134 --- /dev/null +++ b/fixtures/badges/ufo/ufo-16-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-16-light.svg b/fixtures/badges/ufo/ufo-16-light.svg new file mode 100644 index 0000000000..e2fa22e461 --- /dev/null +++ b/fixtures/badges/ufo/ufo-16-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-160.svg b/fixtures/badges/ufo/ufo-160.svg new file mode 100644 index 0000000000..a6d5072e09 --- /dev/null +++ b/fixtures/badges/ufo/ufo-160.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-24-dark.svg b/fixtures/badges/ufo/ufo-24-dark.svg new file mode 100644 index 0000000000..412bcd9f40 --- /dev/null +++ b/fixtures/badges/ufo/ufo-24-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-24-light.svg b/fixtures/badges/ufo/ufo-24-light.svg new file mode 100644 index 0000000000..9e8d21d2fb --- /dev/null +++ b/fixtures/badges/ufo/ufo-24-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-36-dark.svg b/fixtures/badges/ufo/ufo-36-dark.svg new file mode 100644 index 0000000000..6dbc21d2ac --- /dev/null +++ b/fixtures/badges/ufo/ufo-36-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/badges/ufo/ufo-36-light.svg b/fixtures/badges/ufo/ufo-36-light.svg new file mode 100644 index 0000000000..5b79d5718c --- /dev/null +++ b/fixtures/badges/ufo/ufo-36-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index d9f8f5b262..9d0e00e134 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6189,14 +6189,6 @@ button.module-calling-participants-list__contact { } } -.module-background-color { - &__default { - background-color: variables.$color-black-alpha-40; - } - - @include mixins.avatar-colors(); -} - .module-tooltip { --tooltip-text-color: #{variables.$color-gray-75}; --tooltip-background-color: #{variables.$color-gray-02}; diff --git a/ts/axo/AriaClickable.dom.tsx b/ts/axo/AriaClickable.dom.tsx index 25523073ff..0bf27d1341 100644 --- a/ts/axo/AriaClickable.dom.tsx +++ b/ts/axo/AriaClickable.dom.tsx @@ -5,7 +5,7 @@ import type { ReactNode, MouseEvent, FC } from 'react'; import { useLayoutEffect } from '@react-aria/utils'; import { computeAccessibleName } from 'dom-accessibility-api'; import { tw } from './tw.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { assert } from './_internal/assert.std.js'; import { createStrictContext, useStrictContext, diff --git a/ts/axo/AxoAvatar.dom.stories.tsx b/ts/axo/AxoAvatar.dom.stories.tsx new file mode 100644 index 0000000000..53b2736452 --- /dev/null +++ b/ts/axo/AxoAvatar.dom.stories.tsx @@ -0,0 +1,379 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Meta } from '@storybook/react'; +import type { JSX, ReactNode } from 'react'; +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { AxoAvatar } from './AxoAvatar.dom.js'; +import { tw } from './tw.dom.js'; +import { BADGES_FIXTURE } from './_internal/storybook-fixtures.std.js'; +import { _getAllAxoSymbolIconNames } from './_internal/AxoSymbolDefs.generated.std.js'; +import { AxoTokens } from './AxoTokens.std.js'; + +export default { + title: 'Axo/AxoAvatar', +} satisfies Meta; + +function Stack(props: { children: ReactNode }) { + return
{props.children}
; +} + +function Row(props: { children: ReactNode }) { + return ( +
{props.children}
+ ); +} + +function Cell(props: { children: ReactNode; label: ReactNode }) { + return ( +
+ {props.children} + + {props.label} + +
+ ); +} + +function SizesTemplate(props: { + children: (size: AxoAvatar.Size) => ReactNode; +}) { + const sizes = AxoAvatar._getAllSizes(); + return ( + + {sizes.map(size => { + return ( + + {props.children(size)} + + ); + })} + + ); +} + +export function Colors(): JSX.Element { + const colors = AxoTokens.Avatar.getAllColorNames(); + return ( + + + + + + + + + {colors.map(color => { + return ( + + + + + + + + ); + })} + + ); +} + +export function Gradients(): JSX.Element { + const gradientsCount = AxoTokens.Avatar.getGradientsCount(); + return ( + + {Array.from({ length: gradientsCount }, (_, index) => { + return ( + + + + + + + + ); + })} + + ); +} + +export function Images(): JSX.Element { + return ( + + {size => ( + + + + + + )} + + ); +} + +export function BrokenImage(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + ); +} + +export function Initials(): JSX.Element { + return ( + + + {size => ( + + + + + + )} + + + {size => ( + + + + + + )} + + + ); +} + +export function Icons(): JSX.Element { + const icons = _getAllAxoSymbolIconNames(); + return ( + + {size => ( + + + + + + )} + + ); +} + +export function Presets(): JSX.Element { + const contactPresets = AxoTokens.Avatar.getAllContactPresetNames(); + const groupPresets = AxoTokens.Avatar.getAllGroupPresetNames(); + return ( + + + {contactPresets.map(preset => { + return ( + + + + + + + + ); + })} + + + {groupPresets.map(preset => { + return ( + + + + + + + + ); + })} + + + ); +} + +function ActionsTemplate(props: { ring: boolean }) { + const ring = props.ring ? 'unread' : null; + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function Actions(): JSX.Element { + return ( + + +

With focus ring

+ +
+ ); +} + +export function Badges(): JSX.Element { + return ( + + {Object.values(BADGES_FIXTURE).map(badge => { + return ( + + {size => ( + + + + + + + )} + + ); + })} + + ); +} + +export function Stories(): JSX.Element { + return ( + + + {size => ( + + + + + + + )} + + + {size => ( + + + + + + )} + + + ); +} + +export function ClickToView(): JSX.Element { + const sizes = AxoAvatar._getAllSizes().filter(size => { + return size >= AxoAvatar.MIN_CLICK_TO_VIEW_SIZE; + }); + + return ( + + {sizes.map(size => { + return ( + + + + + + + + + ); + })} + + ); +} diff --git a/ts/axo/AxoAvatar.dom.tsx b/ts/axo/AxoAvatar.dom.tsx new file mode 100644 index 0000000000..147cb935f5 --- /dev/null +++ b/ts/axo/AxoAvatar.dom.tsx @@ -0,0 +1,526 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + CSSProperties, + FC, + ImgHTMLAttributes, + MouseEventHandler, + ReactNode, +} from 'react'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { AxoSymbol } from './AxoSymbol.dom.js'; +import type { TailwindStyles } from './tw.dom.js'; +import { tw } from './tw.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; +import { assert } from './_internal/assert.std.js'; +import { AxoTokens } from './AxoTokens.std.js'; + +const Namespace = 'AxoAvatar'; + +/** + * @example Anatomy + * ```tsx + * + * + * { + * || + * || + * || + * + * } + * + * + * + * + * ``` + */ +export namespace AxoAvatar { + export type Size = + | 20 + | 24 + | 28 + | 30 + | 32 + | 36 + | 40 + | 48 + | 52 + | 64 + | 72 + | 80 + | 96 + | 216; + + const SizeContext = createStrictContext(`${Namespace}.Root`); + + const RootSizes: Record = { + 20: tw('size-[20px]'), + 24: tw('size-[24px]'), + 28: tw('size-[28px]'), + 30: tw('size-[30px]'), + 32: tw('size-[32px]'), + 36: tw('size-[36px]'), + 40: tw('size-[40px]'), + 48: tw('size-[48px]'), + 52: tw('size-[52px]'), + 64: tw('size-[64px]'), + 72: tw('size-[72px]'), + 80: tw('size-[80px]'), + 96: tw('size-[96px]'), + 216: tw('size-[216px]'), + }; + + const RingSizes: Record = { + 20: tw('border-[1px] p-[1.5px]'), + 24: tw('border-[1px] p-[1.5px]'), + 28: tw('border-[1.5px] p-[2px]'), + 30: tw('border-[1.5px] p-[2px]'), + 32: tw('border-[1.5px] p-[2px]'), + 36: tw('border-[1.5px] p-[2px]'), + 40: tw('border-[1.5px] p-[2px]'), + 48: tw('border-[2px] p-[3px]'), + 52: tw('border-[2px] p-[3px]'), + 64: tw('border-[2px] p-[3px]'), + 72: tw('border-[2.5px] p-[3.5px]'), + 80: tw('border-[2.5px] p-[3.5px]'), + 96: tw('border-[3px] p-[4px]'), + 216: tw('border-[4px] p-[6px]'), + }; + + export function _getAllSizes(): ReadonlyArray { + return Object.keys(RootSizes).map(size => Number(size) as Size); + } + + const DefaultColor = tw('bg-fill-secondary text-label-primary'); + + /** + * Component: + * --------------------------- + */ + + export type RootProps = Readonly<{ + size: Size; + ring?: 'unread' | 'read' | null; + children: ReactNode; + }>; + + export const Root: FC = memo(props => { + return ( + +
+ {props.children} +
+
+ ); + }); + + Root.displayName = `${Namespace}.Root`; + + /** + * Component: + * ------------------------------ + */ + + export type ContentProps = Readonly<{ + label: string | null; + onClick?: MouseEventHandler | null; + children: ReactNode; + }>; + + export const Content: FC = memo(props => { + const ariaLabel = props.label ?? undefined; + const baseClassName = tw('relative size-full rounded-full contain-strict'); + + let result: ReactNode; + if (props.onClick != null) { + result = ( + + ); + } else { + result = ( +
+ {props.children} +
+ ); + } + return result; + }); + + Content.displayName = `${Namespace}.Content`; + + /** + * Component: + * --------------------------- + */ + + export type IconProps = Readonly<{ + symbol: AxoSymbol.IconName; + }>; + + export const Icon: FC = memo(props => { + const size = useStrictContext(SizeContext); + return ( + + + + ); + }); + + Icon.displayName = `${Namespace}.Icon`; + + /** + * Component: + * ---------------------------- + */ + + export type ImageProps = Readonly<{ + src: string; + srcWidth: number; + srcHeight: number; + blur: boolean; + fallbackIcon: AxoSymbol.IconName; + fallbackColor: AxoTokens.Avatar.ColorName; + }>; + + export const Image: FC = memo(props => { + const { src } = props; + + const ref = useRef(null); + const [loadedSrc, setLoadedSrc] = useState(null); + const [brokenSrc, setBrokenSrc] = useState(null); + + const isLoaded = src === loadedSrc; + const isBroken = src === brokenSrc; + + const handleError = useCallback(() => { + setBrokenSrc(src); + }, [src]); + + const handleLoad = useCallback(() => { + setLoadedSrc(src); + }, [src]); + + if (!isLoaded && isBroken) { + const color = AxoTokens.Avatar.getColorValues(props.fallbackColor); + return ( +
+ +
+ ); + } + + return ( + // eslint-disable-next-line jsx-a11y/alt-text + + ); + }); + + Image.displayName = `${Namespace}.Image`; + + /** + * Component: + */ + + export type PresetProps = Readonly<{ + preset: AxoTokens.Avatar.PresetName; + }>; + + export const Preset: FC = memo(props => { + const { preset } = props; + + const src = useMemo(() => { + return `images/avatars/avatar_${preset}.svg`; + }, [preset]); + + const style = useMemo((): CSSProperties => { + const colorName = AxoTokens.Avatar.getPresetColorName(preset); + const color = AxoTokens.Avatar.getColorValues(colorName); + return { background: color.bg }; + }, [preset]); + + return ( + // eslint-disable-next-line jsx-a11y/alt-text + + ); + }); + + Preset.displayName = `${Namespace}.Preset`; + + /** + * Component: + * ---------------------------------- + */ + + export const MIN_CLICK_TO_VIEW_SIZE = 80; + + export type ClickToViewProps = Readonly<{ + label: string; + }>; + + export const ClickToView: FC = memo(props => { + const size = useStrictContext(SizeContext); + + assert( + size >= MIN_CLICK_TO_VIEW_SIZE, + `Cannot render ${Namespace}.ClickToView at a size smaller than ${MIN_CLICK_TO_VIEW_SIZE}` + ); + + return ( +
+ + {props.label} +
+ ); + }); + + ClickToView.displayName = `${Namespace}.ClickToView`; + + /** + * Component: + * ------------------------------- + */ + + export type InitialsProps = Readonly<{ + initials: string; + color: AxoTokens.Avatar.ColorName; + }>; + + export const Initials: FC = memo(props => { + const style = useMemo((): CSSProperties => { + const color = AxoTokens.Avatar.getColorValues(props.color); + return { fill: color.fg, background: color.bg }; + }, [props.color]); + + return ( + + + {props.initials} + + + ); + }); + + Initials.displayName = `${Namespace}.Initials`; + + /** + * Component: + * ------------------------------- + */ + + export type GradientProps = Readonly<{ + identifierHash: number; + }>; + + export const Gradient: FC = memo(props => { + const { identifierHash } = props; + const style = useMemo((): CSSProperties => { + const gradient = AxoTokens.Avatar.getGradientValuesByHash(identifierHash); + return { + backgroundImage: + AxoTokens.Avatar.gradientToCssBackgroundImage(gradient), + }; + }, [identifierHash]); + return ( +
+ ); + }); + + Gradient.displayName = `${Namespace}.Gradient`; + + /** + * Component: + * ---------------------------- + */ + + export type BadgeSvg = Readonly<{ + light: string; + dark: string; + }>; + + export type BadgeSvgs = Readonly<{ + 16: BadgeSvg; + 24: BadgeSvg; + 36: BadgeSvg; + }>; + + export type BadgeProps = Readonly<{ + label: string; + svgs: BadgeSvgs; + onClick?: MouseEventHandler | null; + }>; + + const BadgeSvgSizes: Record = { + 20: null, + 24: null, + 28: 16, + 30: 16, + 32: 16, + 36: 16, + 40: 24, + 48: 24, + 52: 24, + 64: 24, + 72: 36, + 80: 36, + 96: 36, + 216: 36, + }; + + export const Badge: FC = memo(props => { + const { svgs } = props; + const avatarSize = useStrictContext(SizeContext); + + const badge = useMemo(() => { + const badgeSize = BadgeSvgSizes[avatarSize]; + if (badgeSize == null) { + return null; + } + const svg = svgs[badgeSize]; + if (svg == null) { + return null; + } + return { size: badgeSize, light: svg.light, dark: svg.dark }; + }, [svgs, avatarSize]); + + if (badge == null) { + return null; + } + + const baseImageProps: Omit< + ImgHTMLAttributes, + 'src' | 'className' + > = { + width: badge.size, + height: badge.size, + decoding: 'async', + fetchPriority: 'low', + loading: 'lazy', + draggable: false, + }; + + const children = ( + <> + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + + ); + + const baseClassName = tw( + 'absolute rounded-full', + // Proportionately sized & positioned based on the size of the avatar + '-end-[calc(2.75px-3%)] -bottom-[calc(6.25px-1%)] size-[calc(5px+37.5%)]' + ); + + let result: ReactNode; + if (props.onClick != null) { + result = ( + + ); + } else { + result = ( +
+ {children} +
+ ); + } + + return result; + }); + + Badge.displayName = `${Namespace}.Badge`; +} diff --git a/ts/axo/AxoBadge.dom.tsx b/ts/axo/AxoBadge.dom.tsx index fbba583a72..68df53c580 100644 --- a/ts/axo/AxoBadge.dom.tsx +++ b/ts/axo/AxoBadge.dom.tsx @@ -5,7 +5,7 @@ import React, { memo, useMemo } from 'react'; import { AxoSymbol } from './AxoSymbol.dom.js'; import type { TailwindStyles } from './tw.dom.js'; import { tw } from './tw.dom.js'; -import { unreachable } from './_internal/assert.dom.js'; +import { unreachable } from './_internal/assert.std.js'; const Namespace = 'AxoBadge'; diff --git a/ts/axo/AxoButton.dom.tsx b/ts/axo/AxoButton.dom.tsx index 9b69c06398..04176a4daf 100644 --- a/ts/axo/AxoButton.dom.tsx +++ b/ts/axo/AxoButton.dom.tsx @@ -5,7 +5,7 @@ import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react'; import type { TailwindStyles } from './tw.dom.js'; import { tw } from './tw.dom.js'; import { AxoSymbol } from './AxoSymbol.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { assert } from './_internal/assert.std.js'; import type { SpinnerVariant } from '../components/SpinnerV2.dom.js'; import { SpinnerV2 } from '../components/SpinnerV2.dom.js'; diff --git a/ts/axo/AxoContextMenu.dom.tsx b/ts/axo/AxoContextMenu.dom.tsx index 7f9ee86c63..cb7f7c226e 100644 --- a/ts/axo/AxoContextMenu.dom.tsx +++ b/ts/axo/AxoContextMenu.dom.tsx @@ -18,7 +18,7 @@ import type { import { AxoSymbol } from './AxoSymbol.dom.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; import { tw } from './tw.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { assert } from './_internal/assert.std.js'; import { createStrictContext, useStrictContext, diff --git a/ts/axo/AxoDropdownMenu.dom.tsx b/ts/axo/AxoDropdownMenu.dom.tsx index f84cc88a1f..8a8e91140a 100644 --- a/ts/axo/AxoDropdownMenu.dom.tsx +++ b/ts/axo/AxoDropdownMenu.dom.tsx @@ -20,7 +20,7 @@ import { useAriaLabellingContext, useCreateAriaLabellingContext, } from './_internal/AriaLabellingContext.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { assert } from './_internal/assert.std.js'; import { getElementAriaRole, isAriaWidgetRole, diff --git a/ts/axo/AxoMenuBuilder.dom.tsx b/ts/axo/AxoMenuBuilder.dom.tsx index e5c86ee7e2..b71b4bb2ac 100644 --- a/ts/axo/AxoMenuBuilder.dom.tsx +++ b/ts/axo/AxoMenuBuilder.dom.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react'; import React, { memo } from 'react'; import type { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; -import { unreachable } from './_internal/assert.dom.js'; +import { unreachable } from './_internal/assert.std.js'; import { AxoDropdownMenu } from './AxoDropdownMenu.dom.js'; import { AxoContextMenu } from './AxoContextMenu.dom.js'; import { diff --git a/ts/axo/AxoTokens.std.ts b/ts/axo/AxoTokens.std.ts new file mode 100644 index 0000000000..570153827e --- /dev/null +++ b/ts/axo/AxoTokens.std.ts @@ -0,0 +1,188 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from './_internal/assert.std.js'; + +export namespace AxoTokens { + export type HexColor = `#${string}` & { HexColor: never }; + + function hexColor(input: `#${string}`): HexColor { + return input as HexColor; + } + + export namespace Avatar { + export type ColorName = + | 'A100' + | 'A110' + | 'A120' + | 'A130' + | 'A140' + | 'A150' + | 'A160' + | 'A170' + | 'A180' + | 'A190' + | 'A200' + | 'A210'; + + export type ColorValues = Readonly<{ + bg: HexColor; + fg: HexColor; + }>; + + const Colors: Record = { + A100: { bg: hexColor('#e3e3fe'), fg: hexColor('#3838f5') }, + A110: { bg: hexColor('#dde7fc'), fg: hexColor('#1251d3') }, + A120: { bg: hexColor('#d8e8f0'), fg: hexColor('#086da0') }, + A130: { bg: hexColor('#cde4cd'), fg: hexColor('#067906') }, + A140: { bg: hexColor('#eae0fd'), fg: hexColor('#661aff') }, + A150: { bg: hexColor('#f5e3fe'), fg: hexColor('#9f00f0') }, + A160: { bg: hexColor('#f6d8ec'), fg: hexColor('#b8057c') }, + A170: { bg: hexColor('#f5d7d7'), fg: hexColor('#be0404') }, + A180: { bg: hexColor('#fef5d0'), fg: hexColor('#836b01') }, + A190: { bg: hexColor('#eae6d5'), fg: hexColor('#7d6f40') }, + A200: { bg: hexColor('#d2d2dc'), fg: hexColor('#4f4f6d') }, + A210: { bg: hexColor('#d7d7d9'), fg: hexColor('#5c5c5c') }, + }; + + const ALL_COLOR_NAMES = Object.keys(Colors) as ReadonlyArray; + + export function getColorValues(color: ColorName): ColorValues { + return assert(Colors[color], `Missing avatar color: ${color}`); + } + + export function getAllColorNames(): ReadonlyArray { + return ALL_COLOR_NAMES; + } + + export function getColorNameByHash(hash: number): ColorName { + assert( + Number.isInteger(hash) && hash >= 0, + 'Hash must be positive integer' + ); + return ALL_COLOR_NAMES[hash % ALL_COLOR_NAMES.length]; + } + + export type GradientValues = Readonly<{ + start: HexColor; + end: HexColor; + }>; + + const Gradients: ReadonlyArray = [ + { start: hexColor('#252568'), end: hexColor('#9C8F8F') }, + { start: hexColor('#2A4275'), end: hexColor('#9D9EA1') }, + { start: hexColor('#2E4B5F'), end: hexColor('#8AA9B1') }, + { start: hexColor('#2E426C'), end: hexColor('#7A9377') }, + { start: hexColor('#1A341A'), end: hexColor('#807F6E') }, + { start: hexColor('#464E42'), end: hexColor('#D5C38F') }, + { start: hexColor('#595643'), end: hexColor('#93A899') }, + { start: hexColor('#2C2F36'), end: hexColor('#687466') }, + { start: hexColor('#2B1E18'), end: hexColor('#968980') }, + { start: hexColor('#7B7067'), end: hexColor('#A5A893') }, + { start: hexColor('#706359'), end: hexColor('#BDA194') }, + { start: hexColor('#383331'), end: hexColor('#A48788') }, + { start: hexColor('#924F4F'), end: hexColor('#897A7A') }, + { start: hexColor('#663434'), end: hexColor('#C58D77') }, + { start: hexColor('#8F4B02'), end: hexColor('#AA9274') }, + { start: hexColor('#784747'), end: hexColor('#8C8F6F') }, + { start: hexColor('#747474'), end: hexColor('#ACACAC') }, + { start: hexColor('#49484C'), end: hexColor('#A5A6B5') }, + { start: hexColor('#4A4E4D'), end: hexColor('#ABAFAE') }, + { start: hexColor('#3A3A3A'), end: hexColor('#929887') }, + ]; + + export function getGradientValuesByHash(hash: number): GradientValues { + assert( + Number.isInteger(hash) && hash >= 0, + 'Hash must be positive integer' + ); + return Gradients[hash % Gradients.length]; + } + + export function getGradientsCount(): number { + return Gradients.length; + } + + export function gradientToCssBackgroundImage( + gradient: GradientValues + ): string { + return `linear-gradient(to bottom, ${gradient.start}, ${gradient.end})`; + } + + export type ContactPresetName = + | 'abstract_01' + | 'abstract_02' + | 'abstract_03' + | 'cat' + | 'dog' + | 'fox' + | 'tucan' + | 'pig' + | 'dinosour' + | 'sloth' + | 'incognito' + | 'ghost'; + + export type GroupPresetName = + | 'balloon' + | 'book' + | 'briefcase' + | 'celebration' + | 'drink' + | 'football' + | 'heart' + | 'house' + | 'melon' + | 'soccerball' + | 'sunset' + | 'surfboard'; + + export type PresetName = ContactPresetName | GroupPresetName; + + const ContactPresetColors: Record = { + abstract_01: 'A130', + abstract_02: 'A120', + abstract_03: 'A170', + cat: 'A190', + dog: 'A140', + fox: 'A190', + tucan: 'A120', + pig: 'A160', + dinosour: 'A130', + sloth: 'A180', + incognito: 'A210', + ghost: 'A100', + }; + + const GroupPresetColors: Record = { + balloon: 'A180', + book: 'A120', + briefcase: 'A110', + celebration: 'A170', + drink: 'A100', + football: 'A210', + heart: 'A100', + house: 'A180', + melon: 'A120', + soccerball: 'A110', + sunset: 'A130', + surfboard: 'A210', + }; + + const PresetColors = { ...ContactPresetColors, ...GroupPresetColors }; + + export function getAllContactPresetNames(): ReadonlyArray { + return Object.keys( + ContactPresetColors + ) as ReadonlyArray; + } + + export function getAllGroupPresetNames(): ReadonlyArray { + return Object.keys(GroupPresetColors) as ReadonlyArray; + } + + export function getPresetColorName(preset: PresetName): ColorName { + return assert(PresetColors[preset], `Missing avatar preset: ${preset}`); + } + } +} diff --git a/ts/axo/AxoTooltip.dom.tsx b/ts/axo/AxoTooltip.dom.tsx index aa4bf32f05..eedb5d3d50 100644 --- a/ts/axo/AxoTooltip.dom.tsx +++ b/ts/axo/AxoTooltip.dom.tsx @@ -12,7 +12,7 @@ import React, { import { Tooltip, Direction } from 'radix-ui'; import { computeAccessibleName } from 'dom-accessibility-api'; import { tw } from './tw.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { assert } from './_internal/assert.std.js'; import { getElementAriaRole, isAriaWidgetRole, diff --git a/ts/axo/_internal/ariaRoles.dom.tsx b/ts/axo/_internal/ariaRoles.dom.tsx index ca78ae5623..abad72aa09 100644 --- a/ts/axo/_internal/ariaRoles.dom.tsx +++ b/ts/axo/_internal/ariaRoles.dom.tsx @@ -3,7 +3,7 @@ import type { AriaRole as ReactAriaRole } from 'react'; import { getRole } from 'dom-accessibility-api'; -import { assert } from './assert.dom.js'; +import { assert } from './assert.std.js'; const AbstractRoles = { /** Abstract Roles: https://www.w3.org/TR/wai-aria-1.2/#abstract_roles */ diff --git a/ts/axo/_internal/assert.dom.tsx b/ts/axo/_internal/assert.std.tsx similarity index 100% rename from ts/axo/_internal/assert.dom.tsx rename to ts/axo/_internal/assert.std.tsx diff --git a/ts/axo/_internal/scrollbars.dom.tsx b/ts/axo/_internal/scrollbars.dom.tsx index 9468b5ef97..77ed5f96a0 100644 --- a/ts/axo/_internal/scrollbars.dom.tsx +++ b/ts/axo/_internal/scrollbars.dom.tsx @@ -1,6 +1,6 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { assert } from './assert.dom.js'; +import { assert } from './assert.std.js'; export type ScrollbarWidth = 'auto' | 'thin' | 'none'; diff --git a/ts/axo/_internal/storybook-fixtures.std.tsx b/ts/axo/_internal/storybook-fixtures.std.tsx new file mode 100644 index 0000000000..0c037f22f2 --- /dev/null +++ b/ts/axo/_internal/storybook-fixtures.std.tsx @@ -0,0 +1,79 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +type BadgeFixture = Readonly<{ + id: string; + category: 'donor' | 'other'; + name: string; + description: string; + svg: { size: 160; src: string }; + svgs: { + 16: { size: 16; light: string; dark: string }; + 24: { size: 24; light: string; dark: string }; + 36: { size: 36; light: string; dark: string }; + }; +}>; + +// prettier-ignore +export const BADGES_FIXTURE = { + planet: { + id: 'R_MED', + category: 'donor', + name: 'Signal Planet', + description: '{short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.', + svg: { size: 160, src: 'fixtures/badges/planet/planet-160.svg' }, + svgs: { + 16: { size: 16, light: 'fixtures/badges/planet/planet-16-light.svg', dark: 'fixtures/badges/planet/planet-16-dark.svg' }, + 24: { size: 24, light: 'fixtures/badges/planet/planet-24-light.svg', dark: 'fixtures/badges/planet/planet-24-dark.svg' }, + 36: { size: 36, light: 'fixtures/badges/planet/planet-36-light.svg', dark: 'fixtures/badges/planet/planet-36-dark.svg' }, + }, + }, + rocket: { + id: 'BOOST', + category: 'donor', + name: 'Signal Boost', + description: '{short_name} supported Signal with a donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.', + svg: { size: 160, src: 'fixtures/badges/rocket/rocket-160.svg' }, + svgs: { + 16: { size: 16, light: 'fixtures/badges/rocket/rocket-16-light.svg', dark: 'fixtures/badges/rocket/rocket-16-dark.svg' }, + 24: { size: 24, light: 'fixtures/badges/rocket/rocket-24-light.svg', dark: 'fixtures/badges/rocket/rocket-24-dark.svg' }, + 36: { size: 36, light: 'fixtures/badges/rocket/rocket-36-light.svg', dark: 'fixtures/badges/rocket/rocket-36-dark.svg' }, + }, + }, + star: { + id: 'R_LOW', + category: 'donor', + name: 'Signal Star', + description: '{short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.', + svg: { size: 160, src: 'fixtures/badges/star/star-160.svg' }, + svgs: { + 16: { size: 16, light: 'fixtures/badges/star/star-16-light.svg', dark: 'fixtures/badges/star/star-16-dark.svg' }, + 24: { size: 24, light: 'fixtures/badges/star/star-24-light.svg', dark: 'fixtures/badges/star/star-24-dark.svg' }, + 36: { size: 36, light: 'fixtures/badges/star/star-36-light.svg', dark: 'fixtures/badges/star/star-36-dark.svg' }, + }, + }, + sun: { + id: 'R_HIGH', + category: 'donor', + name: 'Signal Sun', + description: '{short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.', + svg: { size: 160, src: 'fixtures/badges/sun/sun-160.svg' }, + svgs: { + 16: { size: 16, light: 'fixtures/badges/sun/sun-16-light.svg', dark: 'fixtures/badges/sun/sun-16-dark.svg' }, + 24: { size: 24, light: 'fixtures/badges/sun/sun-24-light.svg', dark: 'fixtures/badges/sun/sun-24-dark.svg' }, + 36: { size: 36, light: 'fixtures/badges/sun/sun-36-light.svg', dark: 'fixtures/badges/sun/sun-36-dark.svg' }, + }, + }, + ufo: { + id: 'GIFT', + category: 'donor', + name: 'Signal UFO', + description: 'A friend made a donation to Signal on behalf of {short_name}. Signal is a nonprofit with no advertisers or investors, supported only by people like you.', + svg: { size: 160, src: 'fixtures/badges/ufo/ufo-160.svg' }, + svgs: { + 16: { size: 16, light: 'fixtures/badges/ufo/ufo-16-light.svg', dark: 'fixtures/badges/ufo/ufo-16-dark.svg' }, + 24: { size: 24, light: 'fixtures/badges/ufo/ufo-24-light.svg', dark: 'fixtures/badges/ufo/ufo-24-dark.svg' }, + 36: { size: 36, light: 'fixtures/badges/ufo/ufo-36-light.svg', dark: 'fixtures/badges/ufo/ufo-36-dark.svg' }, + }, + }, +} as const satisfies Record; diff --git a/ts/types/Colors.std.ts b/ts/types/Colors.std.ts index 5211b8b097..e01e5532ca 100644 --- a/ts/types/Colors.std.ts +++ b/ts/types/Colors.std.ts @@ -1,94 +1,15 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -export const AvatarColorMap = new Map([ - [ - 'A100', - { - bg: '#e3e3fe', - fg: '#3838f5', - }, - ], - [ - 'A110', - { - bg: '#dde7fc', - fg: '#1251d3', - }, - ], - [ - 'A120', - { - bg: '#d8e8f0', - fg: '#086da0', - }, - ], - [ - 'A130', - { - bg: '#cde4cd', - fg: '#067906', - }, - ], - [ - 'A140', - { - bg: '#eae0fd', - fg: '#661aff', - }, - ], - [ - 'A150', - { - bg: '#f5e3fe', - fg: '#9f00f0', - }, - ], - [ - 'A160', - { - bg: '#f6d8ec', - fg: '#b8057c', - }, - ], - [ - 'A170', - { - bg: '#f5d7d7', - fg: '#be0404', - }, - ], - [ - 'A180', - { - bg: '#fef5d0', - fg: '#836b01', - }, - ], - [ - 'A190', - { - bg: '#eae6d5', - fg: '#7d6f40', - }, - ], - [ - 'A200', - { - bg: '#d2d2dc', - fg: '#4f4f6d', - }, - ], - [ - 'A210', - { - bg: '#d7d7d9', - fg: '#5c5c5c', - }, - ], -] as const); +import { AxoTokens } from '../axo/AxoTokens.std.js'; -export const AvatarColors = Array.from(AvatarColorMap.keys()).sort(); +export const AvatarColorMap = new Map( + AxoTokens.Avatar.getAllColorNames().map(colorName => { + return [colorName, AxoTokens.Avatar.getColorValues(colorName)]; + }) +); + +export const AvatarColors = AxoTokens.Avatar.getAllColorNames(); export const AVATAR_COLOR_COUNT = AvatarColors.length; @@ -164,7 +85,7 @@ export type CustomColorType = { deg?: number; }; -export type AvatarColorType = (typeof AvatarColors)[number]; +export type AvatarColorType = AxoTokens.Avatar.ColorName; export type ConversationColorType = | (typeof ConversationColors)[number] diff --git a/ts/util/getColorForCallLink.std.ts b/ts/util/getColorForCallLink.std.ts index e071fabca7..611541a1e7 100644 --- a/ts/util/getColorForCallLink.std.ts +++ b/ts/util/getColorForCallLink.std.ts @@ -1,21 +1,20 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { AVATAR_COLOR_COUNT, AvatarColors } from '../types/Colors.std.js'; -import type { AvatarColorType } from '../types/Colors.std.js'; +import { AxoTokens } from '../axo/AxoTokens.std.js'; // See https://github.com/signalapp/ringrtc/blob/49b4b8a16f997c7fa9a429e96aa83f80b2065c63/src/rust/src/lite/call_links/base16.rs#L8 const BASE_16_CONSONANT_ALPHABET = 'bcdfghkmnpqrstxz'; // See https://github.com/signalapp/ringrtc/blob/49b4b8a16f997c7fa9a429e96aa83f80b2065c63/src/rust/src/lite/call_links/base16.rs#L127-L139 -export function getColorForCallLink(rootKey: string): AvatarColorType { +export function getColorForCallLink( + rootKey: string +): AxoTokens.Avatar.ColorName { const rootKeyStart = rootKey.slice(0, 2); const upper = (BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[0]) || 0) * 16; const lower = BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[1]) || 0; const firstByte = upper + lower; - const index = firstByte % AVATAR_COLOR_COUNT; - - return AvatarColors[index]; + return AxoTokens.Avatar.getColorNameByHash(firstByte); } diff --git a/ts/utils/getAvatarPlaceholderGradient.std.ts b/ts/utils/getAvatarPlaceholderGradient.std.ts index 49f1b8afc3..2a15b50683 100644 --- a/ts/utils/getAvatarPlaceholderGradient.std.ts +++ b/ts/utils/getAvatarPlaceholderGradient.std.ts @@ -1,33 +1,11 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -const GRADIENTS = [ - ['#252568', '#9C8F8F'], - ['#2A4275', '#9D9EA1'], - ['#2E4B5F', '#8AA9B1'], - ['#2E426C', '#7A9377'], - ['#1A341A', '#807F6E'], - ['#464E42', '#D5C38F'], - ['#595643', '#93A899'], - ['#2C2F36', '#687466'], - ['#2B1E18', '#968980'], - ['#7B7067', '#A5A893'], - ['#706359', '#BDA194'], - ['#383331', '#A48788'], - ['#924F4F', '#897A7A'], - ['#663434', '#C58D77'], - ['#8F4B02', '#AA9274'], - ['#784747', '#8C8F6F'], - ['#747474', '#ACACAC'], - ['#49484C', '#A5A6B5'], - ['#4A4E4D', '#ABAFAE'], - ['#3A3A3A', '#929887'], -] as const; +import { AxoTokens } from '../axo/AxoTokens.std.js'; export function getAvatarPlaceholderGradient( identifierHash: number ): Readonly<[string, string]> { - const colorIndex = identifierHash % GRADIENTS.length; - - return GRADIENTS[colorIndex]; + const gradient = AxoTokens.Avatar.getGradientValuesByHash(identifierHash); + return [gradient.start, gradient.end]; }