From 6a5182eaa844cda270829b684edcb56b741d2ddb Mon Sep 17 00:00:00 2001
From: automated-signal <37887102+automated-signal@users.noreply.github.com>
Date: Thu, 12 Feb 2026 18:49:28 -0600
Subject: [PATCH] Init AvoAvatar primitive
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
---
.eslintrc.js | 4 +-
fixtures/badges/planet/planet-16-dark.svg | 1 +
fixtures/badges/planet/planet-16-light.svg | 1 +
fixtures/badges/planet/planet-160.svg | 1 +
fixtures/badges/planet/planet-24-dark.svg | 1 +
fixtures/badges/planet/planet-24-light.svg | 1 +
fixtures/badges/planet/planet-36-dark.svg | 1 +
fixtures/badges/planet/planet-36-light.svg | 1 +
fixtures/badges/rocket/rocket-16-dark.svg | 1 +
fixtures/badges/rocket/rocket-16-light.svg | 1 +
fixtures/badges/rocket/rocket-160.svg | 1 +
fixtures/badges/rocket/rocket-24-dark.svg | 1 +
fixtures/badges/rocket/rocket-24-light.svg | 1 +
fixtures/badges/rocket/rocket-36-dark.svg | 1 +
fixtures/badges/rocket/rocket-36-light.svg | 1 +
fixtures/badges/star/star-16-dark.svg | 1 +
fixtures/badges/star/star-16-light.svg | 1 +
fixtures/badges/star/star-160.svg | 1 +
fixtures/badges/star/star-24-dark.svg | 1 +
fixtures/badges/star/star-24-light.svg | 1 +
fixtures/badges/star/star-36-dark.svg | 1 +
fixtures/badges/star/star-36-light.svg | 1 +
fixtures/badges/sun/sun-16-dark.svg | 1 +
fixtures/badges/sun/sun-16-light.svg | 1 +
fixtures/badges/sun/sun-160.svg | 1 +
fixtures/badges/sun/sun-24-dark.svg | 1 +
fixtures/badges/sun/sun-24-light.svg | 1 +
fixtures/badges/sun/sun-36-dark.svg | 1 +
fixtures/badges/sun/sun-36-light.svg | 1 +
fixtures/badges/ufo/ufo-16-dark.svg | 1 +
fixtures/badges/ufo/ufo-16-light.svg | 1 +
fixtures/badges/ufo/ufo-160.svg | 1 +
fixtures/badges/ufo/ufo-24-dark.svg | 1 +
fixtures/badges/ufo/ufo-24-light.svg | 1 +
fixtures/badges/ufo/ufo-36-dark.svg | 1 +
fixtures/badges/ufo/ufo-36-light.svg | 1 +
stylesheets/_modules.scss | 8 -
ts/axo/AriaClickable.dom.tsx | 2 +-
ts/axo/AxoAvatar.dom.stories.tsx | 379 +++++++++++++
ts/axo/AxoAvatar.dom.tsx | 526 ++++++++++++++++++
ts/axo/AxoBadge.dom.tsx | 2 +-
ts/axo/AxoButton.dom.tsx | 2 +-
ts/axo/AxoContextMenu.dom.tsx | 2 +-
ts/axo/AxoDropdownMenu.dom.tsx | 2 +-
ts/axo/AxoMenuBuilder.dom.tsx | 2 +-
ts/axo/AxoTokens.std.ts | 188 +++++++
ts/axo/AxoTooltip.dom.tsx | 2 +-
ts/axo/_internal/ariaRoles.dom.tsx | 2 +-
.../{assert.dom.tsx => assert.std.tsx} | 0
ts/axo/_internal/scrollbars.dom.tsx | 2 +-
ts/axo/_internal/storybook-fixtures.std.tsx | 79 +++
ts/types/Colors.std.ts | 97 +---
ts/util/getColorForCallLink.std.ts | 11 +-
ts/utils/getAvatarPlaceholderGradient.std.ts | 28 +-
54 files changed, 1235 insertions(+), 138 deletions(-)
create mode 100644 fixtures/badges/planet/planet-16-dark.svg
create mode 100644 fixtures/badges/planet/planet-16-light.svg
create mode 100644 fixtures/badges/planet/planet-160.svg
create mode 100644 fixtures/badges/planet/planet-24-dark.svg
create mode 100644 fixtures/badges/planet/planet-24-light.svg
create mode 100644 fixtures/badges/planet/planet-36-dark.svg
create mode 100644 fixtures/badges/planet/planet-36-light.svg
create mode 100644 fixtures/badges/rocket/rocket-16-dark.svg
create mode 100644 fixtures/badges/rocket/rocket-16-light.svg
create mode 100644 fixtures/badges/rocket/rocket-160.svg
create mode 100644 fixtures/badges/rocket/rocket-24-dark.svg
create mode 100644 fixtures/badges/rocket/rocket-24-light.svg
create mode 100644 fixtures/badges/rocket/rocket-36-dark.svg
create mode 100644 fixtures/badges/rocket/rocket-36-light.svg
create mode 100644 fixtures/badges/star/star-16-dark.svg
create mode 100644 fixtures/badges/star/star-16-light.svg
create mode 100644 fixtures/badges/star/star-160.svg
create mode 100644 fixtures/badges/star/star-24-dark.svg
create mode 100644 fixtures/badges/star/star-24-light.svg
create mode 100644 fixtures/badges/star/star-36-dark.svg
create mode 100644 fixtures/badges/star/star-36-light.svg
create mode 100644 fixtures/badges/sun/sun-16-dark.svg
create mode 100644 fixtures/badges/sun/sun-16-light.svg
create mode 100644 fixtures/badges/sun/sun-160.svg
create mode 100644 fixtures/badges/sun/sun-24-dark.svg
create mode 100644 fixtures/badges/sun/sun-24-light.svg
create mode 100644 fixtures/badges/sun/sun-36-dark.svg
create mode 100644 fixtures/badges/sun/sun-36-light.svg
create mode 100644 fixtures/badges/ufo/ufo-16-dark.svg
create mode 100644 fixtures/badges/ufo/ufo-16-light.svg
create mode 100644 fixtures/badges/ufo/ufo-160.svg
create mode 100644 fixtures/badges/ufo/ufo-24-dark.svg
create mode 100644 fixtures/badges/ufo/ufo-24-light.svg
create mode 100644 fixtures/badges/ufo/ufo-36-dark.svg
create mode 100644 fixtures/badges/ufo/ufo-36-light.svg
create mode 100644 ts/axo/AxoAvatar.dom.stories.tsx
create mode 100644 ts/axo/AxoAvatar.dom.tsx
create mode 100644 ts/axo/AxoTokens.std.ts
rename ts/axo/_internal/{assert.dom.tsx => assert.std.tsx} (100%)
create mode 100644 ts/axo/_internal/storybook-fixtures.std.tsx
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 (
+
+ );
+ });
+
+ 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 (
+
+ );
+ });
+
+ 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];
}