From 6cd406ecb79109f77fa323d2c3c8bd4dcd3980f1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 08:10:07 -0700 Subject: [PATCH 1/2] feat(avatar): shape, status dot, working orbit, xs and xl sizes --- src/theme.css | 40 +++++++++++++ src/ui/avatar.tsx | 78 +++++++++++++++++++++--- stories/primitives/avatar.stories.tsx | 85 +++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/theme.css b/src/theme.css index f3c835c5..fb3fd1ba 100644 --- a/src/theme.css +++ b/src/theme.css @@ -605,6 +605,46 @@ } } +/* 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: ""; + inherits: false; + initial-value: 0deg; +} +@keyframes corbits-avatar-orbit { + to { + --corbits-avatar-angle: 360deg; + } +} +.corbits-avatar-orbit { + position: absolute; + inset: -3px; + border-radius: inherit; + padding: 1.5px; + background: conic-gradient( + from var(--corbits-avatar-angle), + transparent 0 35%, + var(--primary-emphasis) 100% + ); + -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% { diff --git a/src/ui/avatar.tsx b/src/ui/avatar.tsx index d304a06a..223863e4 100644 --- a/src/ui/avatar.tsx +++ b/src/ui/avatar.tsx @@ -4,22 +4,56 @@ 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 = { + 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 = { + circle: "rounded-full", + square: "rounded-[var(--avatar-radius,var(--radius))]", +}; + +const STATUS_CLASS: Record = { + working: "bg-primary-emphasis", + ready: "bg-ok", + idle: "bg-muted-foreground", +}; + +const DOT_SIZE: Record = { + xs: "size-1.5", + sm: "size-2", + md: "size-2.5", + lg: "size-3", + xl: "size-3.5", +}; const TONE_CLASS: Record = { neutral: "bg-muted text-muted-foreground", @@ -28,11 +62,13 @@ const TONE_CLASS: Record = { agent3: "bg-success text-success-foreground", }; -const BADGE_SIZE = { +const BADGE_SIZE: Record = { + 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 @@ -44,20 +80,25 @@ export function Avatar({ label, tone = "neutral", size = "md", + shape, + status, + orbit = false, tenantMonogram, className, }: AvatarProps) { return ( + {orbit ? : null} {initials.slice(0, 2)} {tenantMonogram === undefined ? null : ( )} + {status === undefined ? null : ( + + )} ); } @@ -79,6 +131,9 @@ export type AvatarStackItem = { readonly initials: string; readonly label: string; readonly tone?: AvatarTone; + readonly shape?: AvatarShape; + readonly status?: AvatarStatus; + readonly orbit?: boolean; readonly tenantMonogram?: string; }; @@ -86,7 +141,9 @@ 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; }; @@ -97,6 +154,7 @@ export function AvatarStack({ items, max = 4, size = "sm", + shape, className, }: AvatarStackProps) { const visible = items.slice(0, max); @@ -113,6 +171,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 })} @@ -124,6 +187,7 @@ export function AvatarStack({ 0 && "-ml-1.5", )} diff --git a/stories/primitives/avatar.stories.tsx b/stories/primitives/avatar.stories.tsx index 0553d65e..7223c9e0 100644 --- a/stories/primitives/avatar.stories.tsx +++ b/stories/primitives/avatar.stories.tsx @@ -13,9 +13,76 @@ export const Tones = () => ( export const Sizes = () => (
+ + +
+); + +export const Shapes = () => ( +
+ + +
+); + +export const Statuses = () => ( +
+ + + + +
+); + +export const Orbit = () => ( +
+ {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + + ))} +
); @@ -35,3 +102,21 @@ export const Stack = () => ( max={4} /> ); + +export const StackWithStatus = () => ( + +); From 01a89e594738e5235b8667a70a6171c9d6a5c595 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 08:45:17 -0700 Subject: [PATCH 2/2] fix(avatar): draw the orbit as a full ring with a track and visible dark fills --- src/theme.css | 17 +++++++++-------- src/ui/avatar.tsx | 19 +++++++++++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/theme.css b/src/theme.css index fb3fd1ba..61cb7a4d 100644 --- a/src/theme.css +++ b/src/theme.css @@ -620,14 +620,15 @@ } .corbits-avatar-orbit { position: absolute; - inset: -3px; - border-radius: inherit; - padding: 1.5px; - background: conic-gradient( - from var(--corbits-avatar-angle), - transparent 0 35%, - var(--primary-emphasis) 100% - ); + 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); diff --git a/src/ui/avatar.tsx b/src/ui/avatar.tsx index 223863e4..a550d728 100644 --- a/src/ui/avatar.tsx +++ b/src/ui/avatar.tsx @@ -56,7 +56,8 @@ const DOT_SIZE: Record = { }; const TONE_CLASS: Record = { - 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", @@ -94,11 +95,25 @@ export function Avatar({ "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 ? : null} + {orbit ? ( + + ) : null} {initials.slice(0, 2)} {tenantMonogram === undefined ? null : (