feat: switch team member avatars to local assets

This commit is contained in:
777genius 2026-04-19 17:33:44 +03:00
parent 481965f1b4
commit cd52660f88
26 changed files with 198 additions and 19 deletions

View file

@ -11,8 +11,10 @@
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
getMemberRuntimeAdvisoryLabel,
resolveMemberAvatarUrl,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
@ -143,6 +145,7 @@ export class TeamGraphAdapter {
const leadId = `lead:${teamName}`;
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
const avatarMap = buildMemberAvatarMap(teamData.members);
const provisioningPresentation = buildTeamProvisioningPresentation({
progress: provisioningProgress,
members: teamData.members,
@ -158,6 +161,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
leadName,
avatarMap,
pendingApprovalAgents,
leadActivity,
leadContext,
@ -173,6 +177,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
memberNodeIdByAlias,
avatarMap,
spawnStatuses,
pendingApprovalAgents,
activeTools,
@ -369,6 +374,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
leadName: string,
avatarMap: ReadonlyMap<string, string>,
pendingApprovalAgents?: Set<string>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage,
@ -428,7 +434,9 @@ export class TeamGraphAdapter {
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl(leadName, 64),
avatarUrl: leadMember
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
: agentAvatarUrl(leadName, 64),
pendingApproval,
activeTool: activeTool
? {
@ -465,6 +473,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
memberNodeIdByAlias: ReadonlyMap<string, string>,
avatarMap: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -520,7 +529,7 @@ export class TeamGraphAdapter {
spawnStatus: spawn?.status,
launchVisualState: launchPresentation.launchVisualState ?? undefined,
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
avatarUrl: agentAvatarUrl(member.name, 64),
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject

View file

@ -4,9 +4,15 @@
* composes project-specific UI, selectors, and presentation helpers.
*/
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
@ -291,7 +297,6 @@ const MemberPopoverContent = ({
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.teamName
: '';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const {
teamData,
teamMembers,
@ -301,6 +306,8 @@ const MemberPopoverContent = ({
memberSpawnSnapshot,
memberSpawnStatuses,
} = useGraphMemberPopoverContext(teamName, memberName);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const provisioningPresentation =
teamData && teamName

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import {
getTeamColorSet,
getThemedBadge,
@ -5,7 +7,13 @@ import {
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import {
agentAvatarUrl,
buildMemberAvatarMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { MemberHoverCard } from './members/MemberHoverCard';
@ -40,6 +48,12 @@ export const MemberBadge = ({
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const teamMembers = useStore((s) =>
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
@ -53,7 +67,7 @@ export const MemberBadge = ({
const avatar = (
<img
src={agentAvatarUrl(name, avatarSize)}
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"

View file

@ -6,6 +6,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -42,6 +43,7 @@ export const ActiveTasksBlock = ({
const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members);
const avatarMap = buildMemberAvatarMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const entries: ActivityEntry[] = [];
@ -115,7 +117,7 @@ export const ActiveTasksBlock = ({
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -9,7 +9,7 @@ import {
} from '@renderer/components/ui/dialog';
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers';
import { MemberBadge } from '../MemberBadge';
@ -28,6 +28,7 @@ function formatTime(timestamp: string): string {
interface DialogThoughtsContentProps {
group: LeadThoughtGroup;
members?: ResolvedTeamMember[];
memberColor?: string;
onTaskIdClick?: (taskId: string) => void;
onReply?: (message: InboxMessage) => void;
@ -39,6 +40,7 @@ interface DialogThoughtsContentProps {
const DialogThoughtsContent = ({
group,
members,
memberColor,
onTaskIdClick,
onReply,
@ -51,6 +53,7 @@ const DialogThoughtsContent = ({
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
const colors = getTeamColorSet(memberColor ?? '');
const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]);
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
return (
@ -58,7 +61,7 @@ const DialogThoughtsContent = ({
{/* Header */}
<div className="flex items-center gap-2 pb-3">
<img
src={agentAvatarUrl(newest.from, 32)}
src={avatarMap.get(newest.from) ?? agentAvatarUrl(newest.from, 32)}
alt=""
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
@ -193,6 +196,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
) : displayItem?.type === 'lead-thoughts' ? (
<DialogThoughtsContent
group={displayItem.group}
members={members}
memberColor={thoughtMemberColor}
onTaskIdClick={onTaskIdClick}
onReply={onReplyToMessage}

View file

@ -5,6 +5,7 @@ import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
getMemberRuntimeAdvisoryLabel,
@ -41,6 +42,7 @@ export const PendingRepliesBlock = ({
const { isLight } = useTheme();
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members);
const avatarMap = buildMemberAvatarMap(members);
const memberPending = Object.entries(pendingRepliesByMember)
.map(([name, sentAtMs]) => ({
kind: 'member' as const,
@ -111,7 +113,7 @@ export const PendingRepliesBlock = ({
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -38,6 +38,7 @@ import { useStore } from '@renderer/store';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
KANBAN_COLUMN_DISPLAY,
@ -149,6 +150,7 @@ export const TaskDetailDialog = ({
headerExtra,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
const { isLight } = useTheme();
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
@ -697,7 +699,10 @@ export const TaskDetailDialog = ({
style={reviewerBadgeStyle}
>
<img
src={agentAvatarUrl(currentTask.reviewer, 18)}
src={
avatarMap.get(currentTask.reviewer) ??
agentAvatarUrl(currentTask.reviewer, 18)
}
alt=""
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -1,10 +1,15 @@
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -97,6 +102,11 @@ export const MemberCard = ({
// const leadContext = useStore((s) =>
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const launchPresentation = buildMemberLaunchPresentation({
member,
spawnStatus,
@ -173,7 +183,7 @@ export const MemberCard = ({
}}
>
<img
src={agentAvatarUrl(member.name)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -1,11 +1,14 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -52,6 +55,11 @@ export const MemberDetailHeader = ({
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
@ -84,7 +92,7 @@ export const MemberDetailHeader = ({
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 96)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 96)}
alt={member.name}
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -18,6 +18,7 @@ import {
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -92,6 +93,7 @@ export const MemberHoverCard = ({
}))
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const avatarMap = buildMemberAvatarMap(teamMembers);
if (!member) {
return <>{children}</>;
@ -142,7 +144,7 @@ export const MemberHoverCard = ({
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 64)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
alt={member.name}
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -4,7 +4,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
} from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
@ -43,6 +47,7 @@ export const MemberSelect = ({
const { isLight } = useTheme();
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = React.useMemo(() => buildMemberAvatarMap(members), [members]);
const selectedMember = React.useMemo(
() => (value ? members.find((m) => m.name === value) : null),
[members, value]
@ -60,7 +65,7 @@ export const MemberSelect = ({
return (
<span className="inline-flex items-center gap-1.5">
<img
src={agentAvatarUrl(member.name, avatarSize)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
@ -176,7 +181,7 @@ export const MemberSelect = ({
className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<img
src={agentAvatarUrl(m.name, avatarSize)}
src={avatarMap.get(m.name) ?? agentAvatarUrl(m.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"

View file

@ -0,0 +1,38 @@
import avatar01 from '@renderer/assets/participant-avatars/01.png';
import avatar02 from '@renderer/assets/participant-avatars/02.png';
import avatar03 from '@renderer/assets/participant-avatars/03.png';
import avatar04 from '@renderer/assets/participant-avatars/04.png';
import avatar05 from '@renderer/assets/participant-avatars/05.png';
import avatar06 from '@renderer/assets/participant-avatars/06.png';
import avatar07 from '@renderer/assets/participant-avatars/07.png';
import avatar08 from '@renderer/assets/participant-avatars/08.png';
import avatar09 from '@renderer/assets/participant-avatars/09.png';
import avatar10 from '@renderer/assets/participant-avatars/10.png';
import avatar11 from '@renderer/assets/participant-avatars/11.png';
import avatar12 from '@renderer/assets/participant-avatars/12.png';
import avatar13 from '@renderer/assets/participant-avatars/13.png';
export const PARTICIPANT_AVATAR_URLS = [
avatar01,
avatar02,
avatar03,
avatar04,
avatar05,
avatar06,
avatar07,
avatar08,
avatar09,
avatar10,
avatar11,
avatar12,
avatar13,
] as const;
export const LEAD_PARTICIPANT_AVATAR_URL = PARTICIPANT_AVATAR_URLS[0];
export function getParticipantAvatarUrlByIndex(index: number): string {
const normalized =
((Math.trunc(index) % PARTICIPANT_AVATAR_URLS.length) + PARTICIPANT_AVATAR_URLS.length) %
PARTICIPANT_AVATAR_URLS.length;
return PARTICIPANT_AVATAR_URLS[normalized];
}

View file

@ -1,6 +1,12 @@
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
getParticipantAvatarUrlByIndex,
LEAD_PARTICIPANT_AVATAR_URL,
PARTICIPANT_AVATAR_URLS,
} from './memberAvatarCatalog';
import type {
LeadActivityState,
MemberLaunchState,
@ -23,8 +29,26 @@ export function displayMemberName(name: string): string {
return name === 'team-lead' ? 'lead' : name;
}
function hashStringToIndex(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
export function agentAvatarUrl(name: string, size = 64): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
void size;
const normalized = name.trim().toLowerCase();
if (normalized === 'team-lead' || normalized === 'lead') {
return LEAD_PARTICIPANT_AVATAR_URL;
}
// Temporarily disabled external avatar API.
// return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
return getParticipantAvatarUrlByIndex(
hashStringToIndex(normalized) % PARTICIPANT_AVATAR_URLS.length
);
}
export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
@ -567,6 +591,12 @@ interface MemberColorInput {
role?: string;
}
interface MemberAvatarInput {
name: string;
removedAt?: number | string | null;
agentType?: string;
}
/**
* Build a consistent namecolorName map for all members.
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
@ -578,6 +608,49 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
}
export function buildMemberAvatarMap(members: readonly MemberAvatarInput[]): Map<string, string> {
const map = new Map<string, string>();
const activeMembers = members.filter((member) => !member.removedAt);
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
for (const [index, member] of leadMembers.entries()) {
map.set(member.name, index === 0 ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name));
}
for (const [index, member] of teammateMembers.entries()) {
map.set(
member.name,
getParticipantAvatarUrlByIndex(1 + (index % (PARTICIPANT_AVATAR_URLS.length - 1)))
);
}
for (const member of members) {
if (!map.has(member.name)) {
map.set(
member.name,
isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name)
);
}
}
map.set('user', agentAvatarUrl('user'));
map.set('system', agentAvatarUrl('system'));
return map;
}
export function resolveMemberAvatarUrl(
member: MemberAvatarInput,
avatarMap?: ReadonlyMap<string, string>,
size = 64
): string {
return (
avatarMap?.get(member.name) ??
(isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name, size))
);
}
export const KANBAN_COLUMN_DISPLAY: Record<
'review' | 'approved',
{ label: string; bg: string; text: string }