From b0bf730b7149b83cbcc800c96321220cd6d5bec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:00:00 -0500 Subject: [PATCH 1/9] feat: add AvatarPair --- src/avatar/avatar-pair.test.tsx | 151 +++++++++++++++++++++++++++++++ src/avatar/avatar-pair.tsx | 153 ++++++++++++++++++++++++++++++++ src/avatar/avatar.mdx | 15 ++++ src/avatar/index.ts | 1 + 4 files changed, 320 insertions(+) create mode 100644 src/avatar/avatar-pair.test.tsx create mode 100644 src/avatar/avatar-pair.tsx diff --git a/src/avatar/avatar-pair.test.tsx b/src/avatar/avatar-pair.test.tsx new file mode 100644 index 00000000..087418ba --- /dev/null +++ b/src/avatar/avatar-pair.test.tsx @@ -0,0 +1,151 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' + +import { Avatar } from './avatar' +import { AvatarPair } from './avatar-pair' + +describe('AvatarPair', () => { + it('renders direct Avatar children without wrappers', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('pair')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('pair')) + }) + + it('sets size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '28px', + '--reactist-avatar-pair-spacing': '12px', + '--reactist-avatar-pair-mask': '2px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(5px + 2px)', + }) + }) + + it('sets large size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '80px', + '--reactist-avatar-pair-spacing': '36px', + '--reactist-avatar-pair-mask': '3px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(10px + 3px)', + }) + }) + + it('applies the pair shape class', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('avatarPairShape-rounded') + }) + + it('requires exactly two children at the type level', () => { + const invalidPair = ( + // @ts-expect-error AvatarPair children must be a tuple of two elements + + + + ) + expect(invalidPair).toBeTruthy() + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('custom-pair') + }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Open workspace pair' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('pair')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + , + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) +}) diff --git a/src/avatar/avatar-pair.tsx b/src/avatar/avatar-pair.tsx new file mode 100644 index 00000000..dda1af40 --- /dev/null +++ b/src/avatar/avatar-pair.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' +import { polymorphicComponent } from '../utils/polymorphism' + +import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' + +import styles from './avatar.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' +import type { PolymorphicComponentProps } from '../utils/polymorphism' +import type { AvatarShape, AvatarSize } from './utils' + +type AvatarPairStyle = React.CSSProperties & { + '--reactist-avatar-pair-size': string + '--reactist-avatar-pair-spacing': string + '--reactist-avatar-pair-mask': string + '--reactist-avatar-pair-rounded-radius': string + '--reactist-avatar-pair-rounded-mask-radius': string +} + +const AVATAR_PAIR_MASK_BY_SIZE: Record = { + 80: '3px', + 72: '3px', + 62: '3px', + 50: '3px', + 40: '3px', + 36: '2.5px', + 30: '2.5px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '1.5px', + 16: '1.25px', + 12: '1px', +} + +const AVATAR_PAIR_SPACING_BY_SIZE: Record = { + 80: '36px', + 72: '32px', + 62: '28px', + 50: '22px', + 40: '18px', + 36: '16px', + 30: '14px', + 28: '12px', + 24: '12px', + 20: '10px', + 18: '10px', + 16: '8px', + 12: '6px', +} + +/** + * Props for the `AvatarPair` component. + */ +type AvatarPairOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The paired avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Exactly two paired Avatar children. The first child is the foreground + * avatar (positioned bottom-right); the second is the diagonal overlay + * (positioned top-left, masked where it overlaps the first). + */ + children: readonly [React.ReactElement, React.ReactElement] + + /** + * Test identifier applied to the avatar pair root element. + */ + 'data-testid'?: string + + /** + * AvatarPair owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarPairProps = PolymorphicComponentProps< + ComponentType, + AvatarPairOwnProps, + 'omitClassName' +> + +/** + * Displays two Avatar children with the second avatar positioned diagonally + * above-left of the first avatar. + */ +const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassName'>( + function AvatarPair( + { + as, + size, + shape = 'circle', + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + return ( + + {children} + + ) + }, +) + +function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { + const mask = AVATAR_PAIR_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + + return { + '--reactist-avatar-pair-size': `${size}px`, + '--reactist-avatar-pair-spacing': AVATAR_PAIR_SPACING_BY_SIZE[size], + '--reactist-avatar-pair-mask': mask, + '--reactist-avatar-pair-rounded-radius': roundedRadius, + '--reactist-avatar-pair-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, + } +} + +export { AvatarPair } +export type { AvatarPairProps } diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 3bf4998f..c9fc1199 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -37,6 +37,14 @@ conveys the count to assistive tech. +## Avatar pairs + +Use `AvatarPair` when a surface represents two related people or entities. Pass +the same `size` to the pair and its two direct `Avatar` children. The second +child is positioned diagonally above-left of the first child. + + + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -191,6 +199,13 @@ the component props instead of overriding them directly. --reactist-avatar-group-mask-thickness: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 4px; +} ``` ## What the consumer owns diff --git a/src/avatar/index.ts b/src/avatar/index.ts index c57e70d1..1af786c8 100644 --- a/src/avatar/index.ts +++ b/src/avatar/index.ts @@ -1,2 +1,3 @@ export * from './avatar' export * from './avatar-group' +export * from './avatar-pair' From 459764904df7ce7904e6de3ef039df311c65a5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:00:12 -0500 Subject: [PATCH 2/9] docs: add avatar collection stories --- src/avatar/avatar-pair.stories.tsx | 228 +++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/avatar/avatar-pair.stories.tsx diff --git a/src/avatar/avatar-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx new file mode 100644 index 00000000..9c646381 --- /dev/null +++ b/src/avatar/avatar-pair.stories.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' + +import { Avatar, AvatarPair, Box, Inline, Stack, Text } from '../index' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +const contributors = [ + { name: 'pawel', githubUserId: '61894375' }, + { name: 'craig', githubUserId: '1305500' }, + { name: 'rui', githubUserId: '3165500' }, + { name: 'ricardo', githubUserId: '96476' }, + { name: 'scott', githubUserId: '25244878' }, + { name: 'francesca', githubUserId: '1509326' }, +] as const + +const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const + +function getContributor(index: number) { + return contributors[index % contributors.length] +} + +function getGithubAvatarUrl(githubUserId: string, width: number) { + return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}` +} + +function getGithubSourceMap(githubUserId: string, width: number) { + return { + [width]: getGithubAvatarUrl(githubUserId, width), + [width * 2]: getGithubAvatarUrl(githubUserId, width * 2), + [width * 3]: getGithubAvatarUrl(githubUserId, width * 3), + } +} + +function StoryLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + + + {title} + {description ? ( + + {description} + + ) : null} + + {children} + + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + {label} + + + + ) +} + +function UserAvatar({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps['size'] +}) { + return ( + + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps['size'] +}) { + return +} + +const meta = { + title: 'Components/Avatar/AvatarPair', + component: AvatarPair, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + ), +} satisfies Story From 8b3d147ef58f9fdf2ce018ff0f5b4ac9529136e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 15:00:11 -0500 Subject: [PATCH 3/9] docs: clarify avatar group count and pair child contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AvatarPair JSDoc spells out "exactly two children" and the foreground vs. overlay roles - mdx mirrors that, notes the count renders as +N, and points consumers at aria-label on the group root when it's a labeled entity - corrects stale --reactist-avatar-pair-rounded-radius default (4px → 5px) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index c9fc1199..6550f737 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -11,6 +11,7 @@ import { import * as AvatarStories from './avatar.stories' import * as AvatarGroupStories from './avatar-group.stories' +import * as AvatarPairStories from './avatar-pair.stories' @@ -40,10 +41,11 @@ conveys the count to assistive tech. ## Avatar pairs Use `AvatarPair` when a surface represents two related people or entities. Pass -the same `size` to the pair and its two direct `Avatar` children. The second -child is positioned diagonally above-left of the first child. +the same `size` to the pair and its exactly two direct `Avatar` children. The +first child is the foreground avatar (positioned bottom-right); the second is +positioned diagonally above-left of the first child. - + ## Migrating from the legacy API @@ -204,7 +206,7 @@ the component props instead of overriding them directly. --reactist-avatar-pair-size: 28px; --reactist-avatar-pair-spacing: 12px; --reactist-avatar-pair-mask: 2px; - --reactist-avatar-pair-rounded-radius: 4px; + --reactist-avatar-pair-rounded-radius: 5px; } ``` From 19ec643516c78dddf151adbfc884a6e2df8e00f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 15:00:24 -0500 Subject: [PATCH 4/9] refactor: drop non-null assertions in avatar collection stories Tighten getContributor / getWorkspaceName return types and extract the workspace helper so callers don't need ! everywhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar-pair.stories.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/avatar/avatar-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx index 9c646381..b636471c 100644 --- a/src/avatar/avatar-pair.stories.tsx +++ b/src/avatar/avatar-pair.stories.tsx @@ -15,8 +15,12 @@ const contributors = [ const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const -function getContributor(index: number) { - return contributors[index % contributors.length] +function getContributor(index: number): (typeof contributors)[number] { + return contributors[index % contributors.length]! +} + +function getWorkspaceName(index: number): (typeof workspaceNames)[number] { + return workspaceNames[index % workspaceNames.length]! } function getGithubAvatarUrl(githubUserId: string, width: number) { @@ -194,8 +198,8 @@ export const Sizes = { {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( - - + + ))} @@ -210,14 +214,8 @@ export const Sizes = { {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( - - + + ))} From 7c5f2407527e00a47287be8c2ec387f546d4d041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 23:30:30 -0500 Subject: [PATCH 5/9] test: trim AvatarPair visual coverage --- src/avatar/avatar-pair.test.tsx | 43 --------------------------------- 1 file changed, 43 deletions(-) diff --git a/src/avatar/avatar-pair.test.tsx b/src/avatar/avatar-pair.test.tsx index 087418ba..35aadc09 100644 --- a/src/avatar/avatar-pair.test.tsx +++ b/src/avatar/avatar-pair.test.tsx @@ -21,49 +21,6 @@ describe('AvatarPair', () => { expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('pair')) }) - it('sets size-derived pair variables', () => { - render( - - - - , - ) - - expect(screen.getByTestId('pair')).toHaveStyle({ - '--reactist-avatar-pair-size': '28px', - '--reactist-avatar-pair-spacing': '12px', - '--reactist-avatar-pair-mask': '2px', - '--reactist-avatar-pair-rounded-mask-radius': 'calc(5px + 2px)', - }) - }) - - it('sets large size-derived pair variables', () => { - render( - - - - , - ) - - expect(screen.getByTestId('pair')).toHaveStyle({ - '--reactist-avatar-pair-size': '80px', - '--reactist-avatar-pair-spacing': '36px', - '--reactist-avatar-pair-mask': '3px', - '--reactist-avatar-pair-rounded-mask-radius': 'calc(10px + 3px)', - }) - }) - - it('applies the pair shape class', () => { - render( - - - - , - ) - - expect(screen.getByTestId('pair')).toHaveClass('avatarPairShape-rounded') - }) - it('requires exactly two children at the type level', () => { const invalidPair = ( // @ts-expect-error AvatarPair children must be a tuple of two elements From 9dec2543f71730537f451ccdf125fee6600402f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 23:33:20 -0500 Subject: [PATCH 6/9] refactor: split AvatarPair styles --- src/avatar/avatar-pair.module.css | 88 +++++++++++++++++++++++++++++++ src/avatar/avatar-pair.tsx | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/avatar/avatar-pair.module.css diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css new file mode 100644 index 00000000..6ceab76a --- /dev/null +++ b/src/avatar/avatar-pair.module.css @@ -0,0 +1,88 @@ +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; + --reactist-avatar-pair-rounded-mask-radius: 7px; + --reactist-avatar-pair-rounded-mask-start: calc( + var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask) + ); + --reactist-avatar-pair-first-center-x: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + --reactist-avatar-pair-first-center-y: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + + position: relative; + width: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); + height: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); +} + +.avatarPair > * { + position: absolute; +} + +.avatarPair > :first-child { + right: 0; + bottom: 0; +} + +.avatarPair > :last-child { + top: 0; + left: 0; +} + +.avatarPairShape-circle > :last-child { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); +} + +.avatarPairShape-rounded > :last-child { + -webkit-mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + -webkit-mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/src/avatar/avatar-pair.tsx b/src/avatar/avatar-pair.tsx index dda1af40..f01ea48f 100644 --- a/src/avatar/avatar-pair.tsx +++ b/src/avatar/avatar-pair.tsx @@ -7,7 +7,7 @@ import { polymorphicComponent } from '../utils/polymorphism' import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' -import styles from './avatar.module.css' +import styles from './avatar-pair.module.css' import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' From 73985c381ea3cb31b7f3c0c25b743d472d152752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 23:36:37 -0500 Subject: [PATCH 7/9] refactor: move AvatarPair sizing to css --- src/avatar/avatar-pair.module.css | 95 ++++++++++++++++++++++++++++++- src/avatar/avatar-pair.tsx | 57 +------------------ 2 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css index 6ceab76a..4216e825 100644 --- a/src/avatar/avatar-pair.module.css +++ b/src/avatar/avatar-pair.module.css @@ -3,7 +3,9 @@ --reactist-avatar-pair-spacing: 12px; --reactist-avatar-pair-mask: 2px; --reactist-avatar-pair-rounded-radius: 5px; - --reactist-avatar-pair-rounded-mask-radius: 7px; + --reactist-avatar-pair-rounded-mask-radius: calc( + var(--reactist-avatar-pair-rounded-radius) + var(--reactist-avatar-pair-mask) + ); --reactist-avatar-pair-rounded-mask-start: calc( var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask) ); @@ -19,6 +21,97 @@ height: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); } +.avatarPairSize-80 { + --reactist-avatar-pair-size: 80px; + --reactist-avatar-pair-spacing: 36px; + --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-rounded-radius: 10px; +} + +.avatarPairSize-72 { + --reactist-avatar-pair-size: 72px; + --reactist-avatar-pair-spacing: 32px; + --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-rounded-radius: 10px; +} + +.avatarPairSize-62 { + --reactist-avatar-pair-size: 62px; + --reactist-avatar-pair-spacing: 28px; + --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-rounded-radius: 8.5px; +} + +.avatarPairSize-50 { + --reactist-avatar-pair-size: 50px; + --reactist-avatar-pair-spacing: 22px; + --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-rounded-radius: 7px; +} + +.avatarPairSize-40 { + --reactist-avatar-pair-size: 40px; + --reactist-avatar-pair-spacing: 18px; + --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-rounded-radius: 5.5px; +} + +.avatarPairSize-36 { + --reactist-avatar-pair-size: 36px; + --reactist-avatar-pair-spacing: 16px; + --reactist-avatar-pair-mask: 2.5px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-30 { + --reactist-avatar-pair-size: 30px; + --reactist-avatar-pair-spacing: 14px; + --reactist-avatar-pair-mask: 2.5px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-28 { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-24 { + --reactist-avatar-pair-size: 24px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 3.2px; +} + +.avatarPairSize-20 { + --reactist-avatar-pair-size: 20px; + --reactist-avatar-pair-spacing: 10px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 3px; +} + +.avatarPairSize-18 { + --reactist-avatar-pair-size: 18px; + --reactist-avatar-pair-spacing: 10px; + --reactist-avatar-pair-mask: 1.5px; + --reactist-avatar-pair-rounded-radius: 3px; +} + +.avatarPairSize-16 { + --reactist-avatar-pair-size: 16px; + --reactist-avatar-pair-spacing: 8px; + --reactist-avatar-pair-mask: 1.25px; + --reactist-avatar-pair-rounded-radius: 2px; +} + +.avatarPairSize-12 { + --reactist-avatar-pair-size: 12px; + --reactist-avatar-pair-spacing: 6px; + --reactist-avatar-pair-mask: 1px; + --reactist-avatar-pair-rounded-radius: 1.6px; +} + .avatarPair > * { position: absolute; } diff --git a/src/avatar/avatar-pair.tsx b/src/avatar/avatar-pair.tsx index f01ea48f..3fd1d7cf 100644 --- a/src/avatar/avatar-pair.tsx +++ b/src/avatar/avatar-pair.tsx @@ -5,54 +5,12 @@ import classNames from 'classnames' import { Box } from '../box' import { polymorphicComponent } from '../utils/polymorphism' -import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' - import styles from './avatar-pair.module.css' import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' import type { AvatarShape, AvatarSize } from './utils' -type AvatarPairStyle = React.CSSProperties & { - '--reactist-avatar-pair-size': string - '--reactist-avatar-pair-spacing': string - '--reactist-avatar-pair-mask': string - '--reactist-avatar-pair-rounded-radius': string - '--reactist-avatar-pair-rounded-mask-radius': string -} - -const AVATAR_PAIR_MASK_BY_SIZE: Record = { - 80: '3px', - 72: '3px', - 62: '3px', - 50: '3px', - 40: '3px', - 36: '2.5px', - 30: '2.5px', - 28: '2px', - 24: '2px', - 20: '2px', - 18: '1.5px', - 16: '1.25px', - 12: '1px', -} - -const AVATAR_PAIR_SPACING_BY_SIZE: Record = { - 80: '36px', - 72: '32px', - 62: '28px', - 50: '22px', - 40: '18px', - 36: '16px', - 30: '14px', - 28: '12px', - 24: '12px', - 20: '10px', - 18: '10px', - 16: '8px', - 12: '6px', -} - /** * Props for the `AvatarPair` component. */ @@ -121,10 +79,10 @@ const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassNam ref={ref} className={classNames( styles.avatarPair, + styles[`avatarPairSize-${size}`], styles[`avatarPairShape-${shape}`], exceptionallySetClassName, )} - style={getAvatarPairStyle(size)} data-testid={testId} display="inlineBlock" position="relative" @@ -136,18 +94,5 @@ const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassNam }, ) -function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { - const mask = AVATAR_PAIR_MASK_BY_SIZE[size] - const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] - - return { - '--reactist-avatar-pair-size': `${size}px`, - '--reactist-avatar-pair-spacing': AVATAR_PAIR_SPACING_BY_SIZE[size], - '--reactist-avatar-pair-mask': mask, - '--reactist-avatar-pair-rounded-radius': roundedRadius, - '--reactist-avatar-pair-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, - } -} - export { AvatarPair } export type { AvatarPairProps } From b14f57f1ab66beb40b87f6d06e031598dc5c31dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 28 May 2026 09:44:37 -0500 Subject: [PATCH 8/9] refactor: clarify AvatarPair mask CSS Add file-level + per-rule comments explaining the back/front mask cutout. Rename --mask to --mask-thickness, collapse identical --first-center-x/-y into one var. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar-pair.module.css | 122 +++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css index 4216e825..56c1b9c4 100644 --- a/src/avatar/avatar-pair.module.css +++ b/src/avatar/avatar-pair.module.css @@ -1,18 +1,34 @@ +/* + * AvatarPair renders two stacked Avatars: + * - First child (back): anchored bottom-right. + * - Last child (front): anchored top-left, painted on top. + * + * To make the back avatar read as a distinct element on *any* background, we + * cut a transparent hole through the front avatar wherever the back avatar + * peeks through, with a small `--mask-thickness` gap around it. Masks (not + * borders or box-shadow) are used because the parent background is unknown — + * masks make the region truly transparent, so whatever sits behind the pair + * shows through. + * + * Sizing is driven entirely by CSS variables. The `.avatarPairSize-{N}` + * classes (applied from JS) override the defaults per avatar size. + */ + .avatarPair { + /* Inputs (overridden per size by .avatarPairSize-{N}) */ --reactist-avatar-pair-size: 28px; --reactist-avatar-pair-spacing: 12px; - --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-mask-thickness: 2px; --reactist-avatar-pair-rounded-radius: 5px; + + /* Derived (CSS-internal — not part of the JS contract) */ --reactist-avatar-pair-rounded-mask-radius: calc( - var(--reactist-avatar-pair-rounded-radius) + var(--reactist-avatar-pair-mask) + var(--reactist-avatar-pair-rounded-radius) + var(--reactist-avatar-pair-mask-thickness) ); --reactist-avatar-pair-rounded-mask-start: calc( - var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask) - ); - --reactist-avatar-pair-first-center-x: calc( - (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask-thickness) ); - --reactist-avatar-pair-first-center-y: calc( + --reactist-avatar-pair-first-center: calc( (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) ); @@ -24,91 +40,91 @@ .avatarPairSize-80 { --reactist-avatar-pair-size: 80px; --reactist-avatar-pair-spacing: 36px; - --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-mask-thickness: 3px; --reactist-avatar-pair-rounded-radius: 10px; } .avatarPairSize-72 { --reactist-avatar-pair-size: 72px; --reactist-avatar-pair-spacing: 32px; - --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-mask-thickness: 3px; --reactist-avatar-pair-rounded-radius: 10px; } .avatarPairSize-62 { --reactist-avatar-pair-size: 62px; --reactist-avatar-pair-spacing: 28px; - --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-mask-thickness: 3px; --reactist-avatar-pair-rounded-radius: 8.5px; } .avatarPairSize-50 { --reactist-avatar-pair-size: 50px; --reactist-avatar-pair-spacing: 22px; - --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-mask-thickness: 3px; --reactist-avatar-pair-rounded-radius: 7px; } .avatarPairSize-40 { --reactist-avatar-pair-size: 40px; --reactist-avatar-pair-spacing: 18px; - --reactist-avatar-pair-mask: 3px; + --reactist-avatar-pair-mask-thickness: 3px; --reactist-avatar-pair-rounded-radius: 5.5px; } .avatarPairSize-36 { --reactist-avatar-pair-size: 36px; --reactist-avatar-pair-spacing: 16px; - --reactist-avatar-pair-mask: 2.5px; + --reactist-avatar-pair-mask-thickness: 2.5px; --reactist-avatar-pair-rounded-radius: 5px; } .avatarPairSize-30 { --reactist-avatar-pair-size: 30px; --reactist-avatar-pair-spacing: 14px; - --reactist-avatar-pair-mask: 2.5px; + --reactist-avatar-pair-mask-thickness: 2.5px; --reactist-avatar-pair-rounded-radius: 5px; } .avatarPairSize-28 { --reactist-avatar-pair-size: 28px; --reactist-avatar-pair-spacing: 12px; - --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-mask-thickness: 2px; --reactist-avatar-pair-rounded-radius: 5px; } .avatarPairSize-24 { --reactist-avatar-pair-size: 24px; --reactist-avatar-pair-spacing: 12px; - --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-mask-thickness: 2px; --reactist-avatar-pair-rounded-radius: 3.2px; } .avatarPairSize-20 { --reactist-avatar-pair-size: 20px; --reactist-avatar-pair-spacing: 10px; - --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-mask-thickness: 2px; --reactist-avatar-pair-rounded-radius: 3px; } .avatarPairSize-18 { --reactist-avatar-pair-size: 18px; --reactist-avatar-pair-spacing: 10px; - --reactist-avatar-pair-mask: 1.5px; + --reactist-avatar-pair-mask-thickness: 1.5px; --reactist-avatar-pair-rounded-radius: 3px; } .avatarPairSize-16 { --reactist-avatar-pair-size: 16px; --reactist-avatar-pair-spacing: 8px; - --reactist-avatar-pair-mask: 1.25px; + --reactist-avatar-pair-mask-thickness: 1.25px; --reactist-avatar-pair-rounded-radius: 2px; } .avatarPairSize-12 { --reactist-avatar-pair-size: 12px; --reactist-avatar-pair-spacing: 6px; - --reactist-avatar-pair-mask: 1px; + --reactist-avatar-pair-mask-thickness: 1px; --reactist-avatar-pair-rounded-radius: 1.6px; } @@ -116,46 +132,80 @@ position: absolute; } +/* Back avatar: anchored bottom-right, painted underneath. */ .avatarPair > :first-child { right: 0; bottom: 0; } +/* Front avatar: anchored top-left, painted on top — receives the mask cutout. */ .avatarPair > :last-child { top: 0; left: 0; } +/* + * Circle shape: a single radial-gradient cuts a circular hole centered on + * the back avatar, sized to leave a `--mask-thickness` gap around it. + */ .avatarPairShape-circle > :last-child { -webkit-mask-image: radial-gradient( - circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at - var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + circle + calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness) + ) + at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center), transparent 99%, #000 100% ); mask-image: radial-gradient( - circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at - var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + circle + calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness) + ) + at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center), transparent 99%, #000 100% ); } +/* + * Rounded shape: CSS has no built-in "rounded-rectangle cutout" mask, so we + * compose the *visible* (un-cut) area of the front avatar from three mask + * layers. What's left over — the bottom-right rectangle with a rounded + * top-left corner — is the hole that reveals the back avatar. + * + * strip = spacing − mask-thickness (thickness of the L-shaped frame) + * corner = rounded-mask-radius (radius of the L's inner corner) + * + * Layer 1 — top strip of the L: 100% wide, strip tall, at (0, 0). + * Layer 2 — left strip of the L: strip wide, 100% tall, at (0, 0). + * Layer 3 — rounded inner corner fill: a corner × corner tile at + * (strip, strip), filled by a radial-gradient that's opaque + * outside the corner radius and transparent inside, so it rounds + * the L's inner ┘ corner where the strips meet. + */ .avatarPairShape-rounded > :last-child { -webkit-mask-image: - linear-gradient(#000 0 0), linear-gradient(#000 0 0), - radial-gradient( - circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, - transparent 99%, - #000 100% - ); + linear-gradient(#000 0 0), + /* layer 1: top strip */ linear-gradient(#000 0 0), + /* layer 2: left strip */ + radial-gradient( + /* layer 3: rounded inner corner */ circle + var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); mask-image: - linear-gradient(#000 0 0), linear-gradient(#000 0 0), - radial-gradient( - circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, - transparent 99%, - #000 100% - ); + linear-gradient(#000 0 0), + /* layer 1: top strip */ linear-gradient(#000 0 0), + /* layer 2: left strip */ + radial-gradient( + /* layer 3: rounded inner corner */ circle + var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); -webkit-mask-position: 0 0, 0 0, From 576560ca50360e500f7631442a2fd75813045599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 28 May 2026 09:45:40 -0500 Subject: [PATCH 9/9] style: keep AvatarPair mask layer labels out of prettier's path Inline /* layer N */ comments after gradient list items got shuffled onto the wrong items by prettier. Replaced with one comment block above the rule documenting the 1:1 layer ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar-pair.module.css | 34 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css index 56c1b9c4..bb64c245 100644 --- a/src/avatar/avatar-pair.module.css +++ b/src/avatar/avatar-pair.module.css @@ -186,26 +186,24 @@ * the L's inner ┘ corner where the strips meet. */ .avatarPairShape-rounded > :last-child { + /* The three mask layers below correspond 1:1 to the L-shape construction + * described above (layer 1 = top strip, layer 2 = left strip, layer 3 = + * rounded inner corner). The same ordering applies to mask-position and + * mask-size. */ -webkit-mask-image: - linear-gradient(#000 0 0), - /* layer 1: top strip */ linear-gradient(#000 0 0), - /* layer 2: left strip */ - radial-gradient( - /* layer 3: rounded inner corner */ circle - var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, - transparent 99%, - #000 100% - ); + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); mask-image: - linear-gradient(#000 0 0), - /* layer 1: top strip */ linear-gradient(#000 0 0), - /* layer 2: left strip */ - radial-gradient( - /* layer 3: rounded inner corner */ circle - var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, - transparent 99%, - #000 100% - ); + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); -webkit-mask-position: 0 0, 0 0,