diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7a8a245b68..6649215245 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1395,20 +1395,67 @@ select:disabled, font-weight: 650; } -.shell-ch-orbit { +.wb-av { + position: relative; + display: inline-flex; + flex: 0 0 auto; +} + +.wb-av-st { position: absolute; - inset: 0; - border: 1.5px solid transparent; - border-top-color: var(--primary); - border-right-color: var(--primary); - border-bottom-color: var(--primary); + right: -3px; + bottom: -3px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--background); + background: var(--muted-foreground); +} + +.wb-av-st--ready { + background: var(--success); +} + +.wb-av-st--idle { + background: color-mix(in srgb, var(--muted-foreground) 50%, transparent); +} + +/* The arc travels a ring concentric with the mark. The conic angle is what + animates, so the masked shape itself never rotates. */ +@property --orbit-a { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + +.wb-av-orbit { + position: absolute; + inset: -3.5px; + padding: 1.5px; + pointer-events: none; + border-radius: calc(var(--av-r) + 3.5px); + background: conic-gradient( + from var(--orbit-a), + transparent 0deg, + color-mix(in srgb, var(--primary) 20%, transparent) 20deg, + var(--primary) 130deg, + transparent 131deg 360deg + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: wb-av-orbit 1.4s linear infinite; +} + +.wb-av[data-kind="person"] .wb-av-orbit { border-radius: 50%; - animation: shell-ch-orbit 1.4s linear infinite; } -@keyframes shell-ch-orbit { +@keyframes wb-av-orbit { to { - transform: rotate(360deg); + --orbit-a: 360deg; } } @@ -1432,7 +1479,7 @@ select:disabled, } @media (prefers-reduced-motion: reduce) { - .shell-ch-orbit { + .wb-av-orbit { animation: none; } .shell-ch-row { diff --git a/apps/web/src/chat/avatar.test.tsx b/apps/web/src/chat/avatar.test.tsx index b8c2359f05..1f5b372f15 100644 --- a/apps/web/src/chat/avatar.test.tsx +++ b/apps/web/src/chat/avatar.test.tsx @@ -1,12 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; import { AVATAR_COLORS, - CORBIT_DEFAULT_COLOR, - CORBIT_GLINT_COLOR, - CORBIT_VISOR_COLOR, - CorbitAvatar, avatarClassForPrincipal, avatarColorClass, avatarColorForPrincipal, @@ -86,43 +81,3 @@ describe("resolveAvatarFill", () => { expect(fill.kind).toBe("generated"); }); }); - -describe("CorbitAvatar", () => { - test("paints its field from the identity token", () => { - const html = renderToStaticMarkup(); - expect(html).toContain(`fill:var(${CORBIT_DEFAULT_COLOR})`); - // The field's old hardcoded fill is gone. The shared visor/glint face - // geometry keeps its fixed constants — it is identical on every agent, - // so it was never part of the per-principal identity palette. - expect(html).not.toContain('fill="#C5D2DE"'); - }); - - test("a chosen palette token paints the field", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("fill:var(--avatar-3)"); - }); - test("renders an SVG with an accessible name and no visible label", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('role="img"'); - expect(html).toContain('aria-label="Myra"'); - expect(html).toContain('data-corbit="true"'); - expect(html).toContain("Myra<"); - }); - - test("contains the visor and glint geometry", () => { - const html = renderToStaticMarkup(); - expect(html).toContain(`fill="${CORBIT_VISOR_COLOR}"`); - expect(html).toContain(`fill="${CORBIT_GLINT_COLOR}"`); - }); - - test("supports named and numeric sizes", () => { - const namedHtml = renderToStaticMarkup(); - expect(namedHtml).toContain("size-6"); - - const numericHtml = renderToStaticMarkup(); - expect(numericHtml).toContain("width:28px"); - expect(numericHtml).toContain("height:28px"); - }); -}); diff --git a/apps/web/src/chat/avatar.tsx b/apps/web/src/chat/avatar.tsx index 8af3de600e..951a143e3a 100644 --- a/apps/web/src/chat/avatar.tsx +++ b/apps/web/src/chat/avatar.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { Avatar, AvatarStack, type AvatarStackItem, type AvatarTone } from "@corbits/react-ui"; import { agentInitials } from "@/chat/threads-api"; @@ -9,6 +9,15 @@ export type AvatarColor = (typeof AVATAR_COLORS)[number]; export const CORBIT_DEFAULT_COLOR: AvatarColor = "--avatar-1"; +// react-ui owns the fill; the token only picks which of its tones a +// principal lands on. +const TONE_BY_COLOR: Record = { + "--avatar-1": "agent", + "--avatar-2": "agent2", + "--avatar-3": "agent3", + "--avatar-4": "neutral", +}; + export const avatarColorClass: Record = { "--avatar-1": "bg-(--avatar-1) text-black", "--avatar-2": "bg-(--avatar-2) text-black", @@ -52,98 +61,119 @@ export function resolveAvatarFill( return { kind: "generated", className: avatarClassForPrincipal(principalId) }; } -export const CORBIT_VISOR_COLOR = "#22252A"; -export const CORBIT_GLINT_COLOR = "#F7EAD5"; - export type CorbitAvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | number; +export type AvatarStatus = "working" | "ready" | "idle"; + +// react-ui ships sm/md/lg only; the wrapper sizes xs/xl/numeric and the +// mark fills it. +const SIZE_PX = { xs: 16, sm: 24, md: 32, lg: 40, xl: 80 } as const; + +function baseSize(px: number): "sm" | "md" | "lg" { + if (px <= 24) return "sm"; + if (px <= 32) return "md"; + return "lg"; +} + +export interface WorkbenchAvatarProps { + /** Workers are rounded squares, people circles. */ + readonly kind: "worker" | "person"; + readonly name: string; + readonly tone?: AvatarTone; + readonly size?: CorbitAvatarSize; + readonly status?: AvatarStatus; + readonly className?: string; +} + +export function WorkbenchAvatar({ + kind, + name, + tone = "agent", + size = "md", + status, + className, +}: WorkbenchAvatarProps) { + const px = typeof size === "number" ? size : SIZE_PX[size]; + const radius = kind === "person" ? "50%" : `${Math.round(px * 0.28)}px`; + const working = kind === "worker" && status === "working"; + return ( + + + {working ?