Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,47 @@
}
}

/* Avatar working orbit. The conic angle is a registered property so the arc
sweeps while the masked ring (and the avatar it encircles) never rotates.
Used by ui/avatar. */
@property --corbits-avatar-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes corbits-avatar-orbit {
to {
--corbits-avatar-angle: 360deg;
}
}
.corbits-avatar-orbit {
position: absolute;
inset: -4px;
padding: 2px;
background:
conic-gradient(
from var(--corbits-avatar-angle),
var(--primary-emphasis) 0deg 90deg,
transparent 90deg 360deg
),
color-mix(in srgb, var(--foreground) 12%, transparent);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
animation: corbits-avatar-orbit 1.4s linear infinite;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.corbits-avatar-orbit {
animation: none;
--corbits-avatar-angle: 120deg;
}
}

/* Ridge-study motion: keep geometry and timing in the original 100 x 70 space. */
@keyframes corbits-thinking-sweep {
0% {
Expand Down
95 changes: 87 additions & 8 deletions src/ui/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,72 @@ import { cn } from "../lib/utils.js";

export type AvatarTone = "neutral" | "agent" | "agent2" | "agent3";

export type AvatarShape = "circle" | "square";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarStatus = "working" | "ready" | "idle";

export type AvatarProps = {
/** One or two characters shown when no image is provided. */
readonly initials: string;
readonly label: string;
readonly tone?: AvatarTone;
readonly size?: "sm" | "md" | "lg";
readonly size?: AvatarSize;
/**
* `circle` is fully round; `square` rounds by `--avatar-radius` (defaults to
* `--radius`). Omitted keeps the unrounded default.
*/
readonly shape?: AvatarShape;
/** Corner dot marking state; the label gains the state for assistive tech. */
readonly status?: AvatarStatus;
/** Orange arc circling the avatar. Static under reduced motion. */
readonly orbit?: boolean;
/** Tenant monogram badge overlaid on the corner. */
readonly tenantMonogram?: string;
readonly className?: string;
};

const SIZE_CLASS = {
const SIZE_CLASS: Record<AvatarSize, string> = {
xs: "size-5 text-[8px]",
sm: "size-6 text-[10px]",
md: "size-8 text-xs",
lg: "size-10 text-sm",
} as const;
xl: "size-14 text-lg",
};

const SHAPE_CLASS: Record<AvatarShape, string> = {
circle: "rounded-full",
square: "rounded-[var(--avatar-radius,var(--radius))]",
};

const STATUS_CLASS: Record<AvatarStatus, string> = {
working: "bg-primary-emphasis",
ready: "bg-ok",
idle: "bg-muted-foreground",
};

const DOT_SIZE: Record<AvatarSize, string> = {
xs: "size-1.5",
sm: "size-2",
md: "size-2.5",
lg: "size-3",
xl: "size-3.5",
};

const TONE_CLASS: Record<AvatarTone, string> = {
neutral: "bg-muted text-muted-foreground",
neutral:
"bg-muted text-muted-foreground dark:bg-[color-mix(in_srgb,var(--foreground)_14%,var(--muted))]",
agent: "bg-primary text-primary-foreground",
agent2: "bg-accent text-accent-foreground",
agent3: "bg-success text-success-foreground",
};

const BADGE_SIZE = {
const BADGE_SIZE: Record<AvatarSize, string> = {
xs: "size-2.5 text-[6px]",
sm: "size-3 text-[7px]",
md: "size-3.5 text-[8px]",
lg: "size-4 text-[9px]",
} as const;
xl: "size-5 text-[10px]",
};

/**
* Initials avatar with optional tenant monogram badge. Images are
Expand All @@ -44,20 +81,39 @@ export function Avatar({
label,
tone = "neutral",
size = "md",
shape,
status,
orbit = false,
tenantMonogram,
className,
}: AvatarProps) {
return (
<span
role="img"
aria-label={label}
aria-label={status === undefined ? label : `${label}, ${status}`}
className={cn(
"relative inline-flex shrink-0 items-center justify-center font-bold uppercase",
SIZE_CLASS[size],
TONE_CLASS[tone],
"dark:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--foreground)_14%,transparent)]",
shape !== undefined && SHAPE_CLASS[shape],
className,
)}
>
{orbit ? (
<span
aria-hidden
className="corbits-avatar-orbit"
style={{
borderRadius:
shape === "circle"
? "9999px"
: shape === "square"
? "calc(var(--avatar-radius, var(--radius)) + 4px)"
: "0",
}}
/>
) : null}
<span aria-hidden>{initials.slice(0, 2)}</span>
{tenantMonogram === undefined ? null : (
<span
Expand All @@ -70,6 +126,17 @@ export function Avatar({
{tenantMonogram.slice(0, 1)}
</span>
)}
{status === undefined ? null : (
<span
aria-hidden
className={cn(
"absolute -right-0.5 -bottom-0.5 rounded-full ring-2 ring-background",
DOT_SIZE[size],
STATUS_CLASS[status],
tenantMonogram !== undefined && "right-auto -left-0.5",
)}
/>
)}
</span>
);
}
Expand All @@ -79,14 +146,19 @@ export type AvatarStackItem = {
readonly initials: string;
readonly label: string;
readonly tone?: AvatarTone;
readonly shape?: AvatarShape;
readonly status?: AvatarStatus;
readonly orbit?: boolean;
readonly tenantMonogram?: string;
};

export type AvatarStackProps = {
readonly items: readonly AvatarStackItem[];
/** Cap visible avatars; remainder becomes a +N chip. */
readonly max?: number;
readonly size?: "sm" | "md";
readonly size?: AvatarSize;
/** Default shape for every item; an item's own `shape` wins. */
readonly shape?: AvatarShape;
readonly className?: string;
};

Expand All @@ -97,6 +169,7 @@ export function AvatarStack({
items,
max = 4,
size = "sm",
shape,
className,
}: AvatarStackProps) {
const visible = items.slice(0, max);
Expand All @@ -113,6 +186,11 @@ export function AvatarStack({
initials={item.initials}
label={item.label}
{...(item.tone === undefined ? {} : { tone: item.tone })}
{...(item.shape === undefined && shape === undefined
? {}
: { shape: item.shape ?? shape })}
{...(item.status === undefined ? {} : { status: item.status })}
{...(item.orbit === undefined ? {} : { orbit: item.orbit })}
{...(item.tenantMonogram === undefined
? {}
: { tenantMonogram: item.tenantMonogram })}
Expand All @@ -124,6 +202,7 @@ export function AvatarStack({
<span
className={cn(
"inline-flex items-center justify-center bg-muted font-mono font-semibold text-muted-foreground ring-1 ring-background",
shape !== undefined && SHAPE_CLASS[shape],
SIZE_CLASS[size],
visible.length > 0 && "-ml-1.5",
)}
Expand Down
85 changes: 85 additions & 0 deletions stories/primitives/avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,76 @@ export const Tones = () => (

export const Sizes = () => (
<div className="flex items-center gap-3">
<Avatar initials="XS" label="Extra small" size="xs" />
<Avatar initials="SM" label="Small" size="sm" />
<Avatar initials="MD" label="Medium" size="md" />
<Avatar initials="LG" label="Large" size="lg" />
<Avatar initials="XL" label="Extra large" size="xl" />
</div>
);

export const Shapes = () => (
<div className="flex items-center gap-3">
<Avatar initials="NP" label="Noor Patel" shape="circle" tone="agent" />
<Avatar initials="AI" label="Agent" shape="square" tone="agent" />
</div>
);

export const Statuses = () => (
<div className="flex items-center gap-4">
<Avatar
initials="AI"
label="Agent"
shape="square"
status="working"
size="lg"
/>
<Avatar
initials="AI"
label="Agent"
shape="square"
status="ready"
size="lg"
/>
<Avatar
initials="AI"
label="Agent"
shape="square"
status="idle"
size="lg"
/>
<Avatar
initials="NP"
label="Noor Patel"
shape="circle"
status="ready"
size="lg"
/>
</div>
);

export const Orbit = () => (
<div className="flex items-center gap-4 p-2">
{(["xs", "sm", "md", "lg", "xl"] as const).map((size) => (
<Avatar
key={size}
initials="AI"
label="Agent"
tone="agent"
shape="square"
status="working"
orbit
size={size}
/>
))}
<Avatar
initials="NP"
label="Noor Patel"
shape="circle"
status="working"
orbit
size="lg"
/>
</div>
);

Expand All @@ -35,3 +102,21 @@ export const Stack = () => (
max={4}
/>
);

export const StackWithStatus = () => (
<AvatarStack
shape="circle"
items={[
{ id: "1", initials: "NP", label: "Noor Patel" },
{
id: "2",
initials: "AI",
label: "Agent",
tone: "agent",
shape: "square",
status: "working",
orbit: true,
},
]}
/>
);
Loading