diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md
index aaf692544a1..695a28ce839 100644
--- a/.claude/rules/sim-settings-pages.md
+++ b/.claude/rules/sim-settings-pages.md
@@ -251,11 +251,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
`RESOURCE_TILE_FILL` for a glyph, `RESOURCE_TILE_PLAIN` for a brand logo or favicon.
-**Member avatars are deliberately two components, not one.** `member-list.tsx`
-renders a 14px neutral marker for the dense Teammates/Organization roster, where
-the email is the primary content; `components/permissions/member-row.tsx` renders
-a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name,
-an email, and a role control. Same shape, different job — do not merge them.
+**One member avatar.** Every member list, owner cell, and ranking renders emcn
+`` — a 14px photo, or the initial
+on the neutral disc (`aria-hidden` because the name is always beside it). Never
+hand-roll an avatar or give a person a `getUserColor` hash; per-person colors
+belong to live collaboration (presence, cursors), where the color matches that
+person's cursor.
## Header action order
diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md
index 02af76f3a8f..9500ac39c73 100644
--- a/.claude/skills/add-settings-page/SKILL.md
+++ b/.claude/skills/add-settings-page/SKILL.md
@@ -55,8 +55,8 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa
`.claude/rules/sim-settings-pages.md` for the token map and the row
title/subtitle pairing convention):
`git grep -nE "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` — should
- be 0. Display type above the scale (`text-[40px]` hero headings, the `text-[8px]`
- member-avatar initial) is deliberate and out of scope.
+ be 0. Display type above the scale (`text-[40px]` hero headings) is deliberate
+ and out of scope.
4. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an
accurate `description` of consistent length with its peers.
- Editable pages: confirm Save/Discard go through `SaveDiscardActions` and
diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc
index 29db1e041e9..7cabb5e583f 100644
--- a/.cursor/rules/sim-settings-pages.mdc
+++ b/.cursor/rules/sim-settings-pages.mdc
@@ -248,11 +248,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
`RESOURCE_TILE_FILL` for a glyph, `RESOURCE_TILE_PLAIN` for a brand logo or favicon.
-**Member avatars are deliberately two components, not one.** `member-list.tsx`
-renders a 14px neutral marker for the dense Teammates/Organization roster, where
-the email is the primary content; `components/permissions/member-row.tsx` renders
-a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name,
-an email, and a role control. Same shape, different job — do not merge them.
+**One member avatar.** Every member list, owner cell, and ranking renders emcn
+`` — a 14px photo, or the initial
+on the neutral disc (`aria-hidden` because the name is always beside it). Never
+hand-roll an avatar or give a person a `getUserColor` hash; per-person colors
+belong to live collaboration (presence, cursors), where the color matches that
+person's cursor.
## Header action order
diff --git a/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx b/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx
index 7d35b87cef2..eb882c155fe 100644
--- a/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx
+++ b/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx
@@ -108,7 +108,7 @@ export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
title={`@${contributor}`}
className='block size-6 shrink-0'
>
-
+
)}
-
+
{initials}
@@ -98,7 +95,7 @@ export function PresenceAvatars({
style={{ zIndex: 0 }}
aria-label={`${overflowCount} more ${overflowCount === 1 ? 'user' : 'users'}`}
>
-
+
+{overflowCount}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts
index 22f3365aa43..fa102e05d3a 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts
@@ -1,2 +1 @@
-export type { OwnerAvatarProps } from './owner-cell'
-export { OwnerAvatar, ownerCell } from './owner-cell'
+export { ownerCell } from './owner-cell'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx
index ebbf3bb9e9b..f3be05b4175 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx
@@ -1,36 +1,7 @@
-import { memo } from 'react'
+import { Avatar } from '@sim/emcn'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
-export interface OwnerAvatarProps {
- name: string
- image: string | null
-}
-
-/**
- * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral
- * disc. Shared so a member reads identically in a resource row's owner cell and in the
- * owner/uploaded-by filter options on every list.
- */
-export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
- if (image) {
- return (
-
- )
- }
-
- return (
-
- {name.charAt(0).toUpperCase()}
-
- )
-})
-
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
* Returns null label while members are still loading to avoid flashing raw IDs.
@@ -51,7 +22,7 @@ export function ownerCell(
if (!member) return { label: null }
return {
- icon: ,
+ icon: ,
label: member.name,
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index 02cf2f87344..95bf5999af8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -562,7 +562,7 @@ const Pagination = memo(function Pagination({
})
interface CellContentProps {
- /** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */
+ /** Pre-rendered icon node (a glyph or a person's `Avatar`); auto-sized to the chip icon size. */
icon?: ReactNode
label: string
content?: ReactNode
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index a0e2198af8f..9a05428538a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
+ Avatar,
Button,
ChipCombobox,
ChipConfirmModal,
@@ -79,10 +80,7 @@ import {
useFolderRowDragDrop,
} from '@/app/workspace/[workspaceId]/components/folders'
import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar'
-import {
- OwnerAvatar,
- ownerCell,
-} from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
+import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
import {
FilesEmptyState,
ResourceNoResults,
@@ -1938,7 +1936,7 @@ function FilesContent() {
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
- iconElement: ,
+ iconElement: ,
})),
[members]
)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index e88c076a636..051acdec1f8 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChipDropdownOption } from '@sim/emcn'
-import { Button, ChipConfirmModal, ChipDropdown, Tooltip, toast } from '@sim/emcn'
+import { Avatar, Button, ChipConfirmModal, ChipDropdown, Tooltip, toast } from '@sim/emcn'
import { Database, FolderPlus, Pencil, Plus, Trash } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -39,10 +39,7 @@ import {
} from '@/app/workspace/[workspaceId]/components/folders'
import { reportBulkOutcome } from '@/app/workspace/[workspaceId]/components/resource/bulk-outcome'
import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar'
-import {
- OwnerAvatar,
- ownerCell,
-} from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
+import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
import {
KnowledgeEmptyState,
ResourceNoResults,
@@ -1331,7 +1328,7 @@ function KnowledgeContent() {
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
- iconElement: ,
+ iconElement: ,
})),
[members]
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts
index 21f94ee9db7..715751e423a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts
@@ -1 +1 @@
-export { MemberAvatar, MemberRow, MemberSection } from './member-list'
+export { MemberRow, MemberSection } from './member-list'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx
index 72ada4c1ef1..2ea78d2cc95 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx
@@ -1,41 +1,13 @@
'use client'
import type { ReactNode } from 'react'
-import { OverflowText } from '@sim/emcn'
+import { Avatar, OverflowText } from '@sim/emcn'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
const ROW_CLASSES = 'flex items-center gap-2.5 p-2'
const ROW_STATUS_CLASSES = 'shrink-0 text-[var(--text-muted)] text-caption'
-interface MemberAvatarProps {
- name: string
- image: string | null
-}
-
-/**
- * 14px circular avatar used in member rows. Falls back to the first letter of
- * the member's name when no image is available.
- */
-export function MemberAvatar({ name, image }: MemberAvatarProps) {
- if (image) {
- return (
-
- )
- }
-
- return (
-
- {name.charAt(0).toUpperCase()}
-
- )
-}
-
interface MemberRowProps {
name: string
email: string
@@ -56,7 +28,7 @@ interface MemberRowProps {
export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) {
return (
-
+
{status}
{roleControl}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
index d57734dbf9e..83f6ed4c0e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
@@ -3,8 +3,6 @@
import { useMemo, useState } from 'react'
import {
Avatar,
- AvatarFallback,
- AvatarImage,
Badge,
Banner,
ChipConfirmModal,
@@ -15,7 +13,6 @@ import {
Skeleton,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import { getUserColor } from '@/lib/workspaces/colors'
import type { RosterMember } from '@/hooks/queries/organization'
interface TransferOwnershipDialogProps {
@@ -167,21 +164,13 @@ export function TransferOwnershipDialog({
aria-pressed={isSelected}
onClick={() => setSelectedUserId(m.userId)}
className={cn(
- 'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
+ 'flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? 'bg-[var(--surface-active)]'
: 'hover-hover:bg-[var(--surface-hover)]'
)}
>
-
- {m.image && }
-
- {m.name.charAt(0).toUpperCase()}
-
-
+
({
value: m.userId,
label: m.name,
- iconElement: ,
+ iconElement: ,
})),
[members]
)
diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx
index 75e3f324074..07cb8fa251a 100644
--- a/apps/sim/components/permissions/member-row.tsx
+++ b/apps/sim/components/permissions/member-row.tsx
@@ -1,7 +1,6 @@
'use client'
-import { Avatar, AvatarFallback, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn'
-import { getUserColor } from '@/lib/workspaces/colors'
+import { Avatar, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn'
import type { MemberRole } from './member-role-options'
import { RoleLockTooltip } from './role-lock'
@@ -9,6 +8,7 @@ export interface MemberRowMember {
userId: string
userName: string | null
userEmail: string | null
+ userImage: string | null
role: TRole
}
@@ -54,14 +54,12 @@ export function MemberRow({
)}
>
-
-
- {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
-
-
+
-
+
}
+ icon={
}
iconVariant='custom'
title={enrollment.email}
description={
diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.tsx
index b641c578950..a141d09ef6d 100644
--- a/apps/sim/ee/credential-groups/components/organization-account-people.tsx
+++ b/apps/sim/ee/credential-groups/components/organization-account-people.tsx
@@ -1,7 +1,7 @@
'use client'
import { type ReactNode, useState } from 'react'
-import { Avatar, AvatarFallback, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
+import { Avatar, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
@@ -111,13 +111,7 @@ export function OrganizationAccountPeople({
{enrollments.map((person) => (
-
- {person.email.charAt(0).toUpperCase()}
-
-
- }
+ icon={}
iconVariant='custom'
title={person.email}
description={}
diff --git a/apps/sim/ee/organization-usage/components/activity-table.tsx b/apps/sim/ee/organization-usage/components/activity-table.tsx
index e4acf0dcbb1..2614f9f9a95 100644
--- a/apps/sim/ee/organization-usage/components/activity-table.tsx
+++ b/apps/sim/ee/organization-usage/components/activity-table.tsx
@@ -1,6 +1,7 @@
'use client'
import {
+ Avatar,
Chip,
OverflowText,
Table,
@@ -13,7 +14,6 @@ import {
import { formatDuration } from '@sim/utils/formatting'
import type { OrganizationActivityBreakdown } from '@/lib/api/contracts/organization-activity'
import type { ActivityDimension } from '@/lib/billing/core/organization-activity'
-import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { formatFailureRate } from '@/ee/organization-usage/components/activity-summary'
interface ActivityTableProps {
@@ -79,7 +79,7 @@ export function ActivityTable({ rows, dimension, onSelectWorkspace }: ActivityTa
) : (
- {isMember &&
}
+ {isMember &&
}
)}
diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx
index 3f6b7ba8670..5a278f134a2 100644
--- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx
+++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ComponentType } from 'react'
-import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn'
+import { Avatar, cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn'
import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
import {
AnthropicIcon,
@@ -34,7 +34,6 @@ import type {
OrganizationUsageBreakdownRow,
UsageBreakdownDimension,
} from '@/lib/api/contracts/organization-usage'
-import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import {
type RowAction,
RowActionsMenu,
@@ -153,7 +152,7 @@ function UsageConsumerRow({
)}
>
{isMember ? (
-
+
) : (
ProviderIcon &&
)}
diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts
index 1a609d95eb3..cb4cc40e35b 100644
--- a/apps/sim/lib/api/contracts/skills.ts
+++ b/apps/sim/lib/api/contracts/skills.ts
@@ -30,7 +30,7 @@ export const skillEditorSchema = z.object({
userId: z.string(),
userName: z.string().nullable(),
userEmail: z.string().nullable(),
- userImage: z.string().nullable().optional(),
+ userImage: z.string().nullable(),
isWorkspaceAdmin: z.boolean(),
})
diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts
index 074b49de7f2..4371c42b324 100644
--- a/apps/sim/lib/credentials/members.test.ts
+++ b/apps/sim/lib/credentials/members.test.ts
@@ -2,8 +2,19 @@
* @vitest-environment node
*/
import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing'
-import { beforeEach, describe, expect, it } from 'vitest'
-import { listCredentialMembershipsForUser } from '@/lib/credentials/members'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetUsersWithPermissions } = vi.hoisted(() => ({
+ mockGetUsersWithPermissions: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: vi.fn(),
+ getUsersWithPermissions: mockGetUsersWithPermissions,
+}))
+
+import { listCredentialMembers, listCredentialMembershipsForUser } from '@/lib/credentials/members'
+import type { CredentialRow } from '@/lib/credentials/queries'
describe('listCredentialMembershipsForUser', () => {
beforeEach(() => {
@@ -21,3 +32,44 @@ describe('listCredentialMembershipsForUser', () => {
])
})
})
+
+describe('listCredentialMembers', () => {
+ beforeEach(() => {
+ resetDbChainMock()
+ })
+
+ it('carries each member’s profile image, explicit and derived alike', async () => {
+ dbChainMockFns.where.mockResolvedValueOnce([
+ {
+ id: 'cm-1',
+ userId: 'user-1',
+ role: 'member',
+ status: 'active',
+ joinedAt: null,
+ userName: 'Ada',
+ userEmail: 'ada@example.com',
+ userImage: 'ada.png',
+ },
+ ])
+ mockGetUsersWithPermissions.mockResolvedValueOnce([
+ {
+ userId: 'user-2',
+ name: 'Sam',
+ email: 'sam@example.com',
+ image: 'sam.png',
+ permissionType: 'admin',
+ },
+ ])
+
+ const members = await listCredentialMembers({
+ id: 'cred-1',
+ workspaceId: 'ws-1',
+ type: 'oauth',
+ } as CredentialRow)
+
+ expect(members.map((member) => [member.userId, member.userImage])).toEqual([
+ ['user-1', 'ada.png'],
+ ['user-2', 'sam.png'],
+ ])
+ })
+})
diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts
index c004c2dc46c..abe6ad0fa9a 100644
--- a/apps/sim/lib/credentials/members.ts
+++ b/apps/sim/lib/credentials/members.ts
@@ -18,6 +18,7 @@ export interface CredentialMemberView {
joinedAt: Date | null
userName: string | null
userEmail: string | null
+ userImage: string | null
roleSource: 'explicit' | 'workspace-admin'
}
@@ -34,6 +35,7 @@ export async function listCredentialMembers(
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
+ userImage: user.image,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
@@ -61,6 +63,7 @@ export async function listCredentialMembers(
joinedAt: null,
userName: workspaceMember.name,
userEmail: workspaceMember.email,
+ userImage: workspaceMember.image ?? null,
roleSource: 'workspace-admin',
})
}
diff --git a/packages/emcn/src/components/avatar/avatar.test.tsx b/packages/emcn/src/components/avatar/avatar.test.tsx
new file mode 100644
index 00000000000..46073a00743
--- /dev/null
+++ b/packages/emcn/src/components/avatar/avatar.test.tsx
@@ -0,0 +1,127 @@
+/** @vitest-environment jsdom */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Avatar, AvatarFallback } from './avatar'
+
+/**
+ * Stands in for the browser's image loader, which Radix drives through
+ * `new window.Image()`: a `src` containing `broken` fails, anything else loads.
+ */
+class FakeImage extends EventTarget {
+ complete = false
+ naturalWidth = 0
+ referrerPolicy = ''
+ crossOrigin: string | null = null
+ private source = ''
+
+ get src() {
+ return this.source
+ }
+
+ set src(value: string) {
+ this.source = value
+ queueMicrotask(() => {
+ const loads = !value.includes('broken')
+ this.complete = true
+ this.naturalWidth = loads ? 1 : 0
+ this.dispatchEvent(new Event(loads ? 'load' : 'error'))
+ })
+ }
+}
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+async function mount(children: ReactNode) {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ await act(async () => {
+ root?.render(children)
+ })
+ await act(async () => {})
+ return container
+}
+
+beforeEach(() => {
+ vi.stubGlobal('Image', FakeImage)
+})
+
+afterEach(() => {
+ act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+ vi.unstubAllGlobals()
+})
+
+describe('Avatar', () => {
+ it('shows the photo once it loads', async () => {
+ const view = await mount()
+ expect(view.querySelector('img')?.getAttribute('src')).toBe('https://example.com/ada.png')
+ expect(view.textContent).toBe('')
+ })
+
+ it('falls back to the initial when the photo fails to load', async () => {
+ const view = await mount()
+ expect(view.querySelector('img')).toBeNull()
+ expect(view.textContent).toBe('A')
+ })
+
+ it('labels a person by their name', async () => {
+ const view = await mount()
+ expect(view.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Ada Lovelace')
+ })
+
+ it('announces a status as part of the person, not as a second image', async () => {
+ const view = await mount()
+ const images = view.querySelectorAll('[role="img"]')
+ expect(images).toHaveLength(1)
+ expect(images[0]?.getAttribute('aria-label')).toBe('Ada, online')
+ expect(view.querySelector('[data-slot="avatar-status"]')?.getAttribute('aria-hidden')).toBe(
+ 'true'
+ )
+ })
+
+ it('announces the status dot itself on a composed avatar', async () => {
+ const view = await mount(
+
+ AL
+
+ )
+ const dot = view.querySelector('[data-slot="avatar-status"]')
+ expect(dot?.getAttribute('role')).toBe('img')
+ expect(dot?.getAttribute('aria-label')).toBe('Status: away')
+ expect(dot?.hasAttribute('aria-hidden')).toBe(false)
+ })
+
+ it('never labels an image with an empty name', async () => {
+ const view = await mount()
+ expect(view.querySelector('[role="img"]')).toBeNull()
+ expect(view.textContent).toBe('?')
+ })
+
+ it('drops the label when the name is already visible beside it', async () => {
+ const view = await mount()
+ expect(view.querySelector('[aria-hidden="true"]')).not.toBeNull()
+ expect(view.querySelector('[aria-label]')).toBeNull()
+ expect(view.querySelector('[role="img"]')).toBeNull()
+ })
+
+ it('sizes the fallback glyph to the avatar, so a 14px disc needs no override', async () => {
+ const xs = await mount()
+ expect(xs.innerHTML).toContain('text-[8px]')
+ act(() => root?.unmount())
+ container?.remove()
+
+ const md = await mount(
+
+ AL
+
+ )
+ expect(md.innerHTML).toContain('text-xs')
+ expect(md.innerHTML).not.toContain('text-[8px]')
+ })
+})
diff --git a/packages/emcn/src/components/avatar/avatar.tsx b/packages/emcn/src/components/avatar/avatar.tsx
index 85ebaa522c8..d3040921d74 100644
--- a/packages/emcn/src/components/avatar/avatar.tsx
+++ b/packages/emcn/src/components/avatar/avatar.tsx
@@ -12,10 +12,10 @@ import { cn } from '../../lib/cn'
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
- xs: 'h-3.5 w-3.5',
- sm: 'h-6 w-6',
- md: 'h-8 w-8',
- lg: 'h-10 w-10',
+ xs: 'size-3.5',
+ sm: 'size-6',
+ md: 'size-8',
+ lg: 'size-10',
},
},
defaultVariants: {
@@ -37,10 +37,10 @@ const avatarStatusVariants = cva(
away: 'bg-[var(--caution)]',
},
size: {
- xs: 'h-1.5 w-1.5 border',
- sm: 'h-2 w-2',
- md: 'h-2.5 w-2.5',
- lg: 'h-3 w-3',
+ xs: 'size-1.5 border',
+ sm: 'size-2',
+ md: 'size-2.5',
+ lg: 'size-3',
},
},
defaultVariants: {
@@ -50,66 +50,124 @@ const avatarStatusVariants = cva(
}
)
+/**
+ * Variant styles for the fallback, keyed by the enclosing avatar's size. `xs` is a
+ * 14px disc, where `text-xs` would overflow the border, so its glyph steps down and
+ * drops the inherited line height that would push it off-center.
+ */
+const avatarFallbackVariants = cva(
+ 'flex size-full items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-4)] font-medium text-[var(--text-secondary)]',
+ {
+ variants: {
+ size: {
+ xs: 'text-[8px] leading-none',
+ sm: 'text-xs',
+ md: 'text-xs',
+ lg: 'text-xs',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ }
+)
+
+type AvatarSize = VariantProps['size']
+
+/** Lets `AvatarFallback` size its glyph to the avatar it sits in. */
+const AvatarSizeContext = React.createContext(undefined)
+
type AvatarStatus = 'online' | 'offline' | 'busy' | 'away'
-interface AvatarProps
- extends React.ComponentPropsWithoutRef,
+interface AvatarBaseProps
+ extends Omit, 'children'>,
VariantProps {
/** Shows a status indicator badge on the avatar */
status?: AvatarStatus
}
+/**
+ * Either a person — `name`, and optionally `src` — or composed `children`. The two
+ * never mix: a composed avatar owns its own image and label.
+ */
+type AvatarProps = AvatarBaseProps &
+ (
+ | {
+ /**
+ * A person's name, used as the accessible label (with any `status`) — pass
+ * `aria-hidden` where the name is already visible beside it. The avatar renders
+ * `src` with the name's initial as its fallback.
+ */
+ name: string
+ /** Photo for the person; the initial shows while it loads, or if it fails. */
+ src?: string | null
+ children?: never
+ }
+ | { name?: never; src?: never; children?: React.ReactNode }
+ )
+
/**
* Avatar component for displaying user profile images with fallback support.
*
* @example
* ```tsx
- * import { Avatar, AvatarImage, AvatarFallback } from '../../index'
+ * import { Avatar, AvatarImage, AvatarFallback } from '@sim/emcn'
*
- * // Basic usage
- *
- *
- * JD
- *
+ * // A person: their photo, falling back to their initial
+ *
*
- * // With size variant
+ * // Composed, for a custom fallback
*
*
* JD
*
*
* // With status indicator
- *
- *
- * JD
- *
- *
- * // All status types
- * // Green
- * // Gray
- * // Red
- * // Yellow/Amber
+ *
* ```
*/
const Avatar = React.forwardRef, AvatarProps>(
- ({ className, size, status, children, ...props }, ref) => (
-
-
- {children}
-
- {status && (
-
- )}
-
- )
+ ({ className, size, status, name, src, children, ...props }, ref) => {
+ const hidden = props['aria-hidden'] === true || props['aria-hidden'] === 'true'
+ const label = name?.trim()
+ const labelled = Boolean(label) && !hidden
+ return (
+
+
+
+ {name === undefined ? (
+ children
+ ) : (
+ <>
+ {src && }
+ {label?.charAt(0).toUpperCase() || '?'}
+ >
+ )}
+
+ {/**
+ * A named avatar carries its status in its own label, and a hidden one
+ * carries nothing; only a composed avatar announces the dot itself.
+ */}
+ {status && (
+
+ )}
+
+
+ )
+ }
)
Avatar.displayName = 'Avatar'
@@ -123,7 +181,7 @@ const AvatarImage = React.forwardRef<
,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
+>(({ className, ...props }, ref) => {
+ const size = React.useContext(AvatarSizeContext)
+ return (
+
+ )
+})
AvatarFallback.displayName = 'AvatarFallback'
-export { Avatar, AvatarImage, AvatarFallback, avatarVariants, avatarStatusVariants }
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ avatarVariants,
+ avatarStatusVariants,
+ avatarFallbackVariants,
+}