diff --git a/DESIGN.md b/DESIGN.md index 5dc60b370f..d6e905f6b4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -153,14 +153,13 @@ and the rest of its semantic palette. Never hardcode a hex value or an arbitrary Tailwind color class in product code; if a needed token doesn't exist yet, add it in react-ui, not locally. -**Generated identity color** is the one deliberate exception: a person's -fallback avatar (no explicit picture) needs a color per principal, not a -handful of shared tokens, so it's the same `colorForPrincipal` hash -already shipped for presence cursors (`@corbits/presence/color`), paired -with a computed black/white initials color for contrast -(`@corbits/chat-ui`'s `generatedAvatarStyle`). Agents keep react-ui's -`Avatar` tone system (solid `--primary`/`--accent`/`--success`) so the two -identity kinds stay visually distinct at a glance. +**Avatar identity** is the one deliberate color exception. A person without +an explicit image receives deterministic initials from the approved pastel +palette. `@corbits/chat-ui` owns the finite Tailwind background/text class +map and resolves a principal to one class pair; consumers never use CSS +variables or inline color styles. Agents use `CorbitAvatar`: a circular +field with a dark lower visor and sensor glint. The two forms stay distinct +at a glance in every collaborative surface. **Type.** Red Hat Display for sans (UI text, headings), Space Mono for monospace (code, IDs, numeric/tabular contexts). Both are declared once in diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 12a9fea0b7..84fe4767f2 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1329,12 +1329,7 @@ select:disabled, outline-offset: -1px; } -/* `Avatar`'s own root span carries `role="img"`, not a `data-slot` — this - used to target a `[data-slot="avatar"]` that react-ui's `Avatar` has - never actually rendered, so the account avatar silently fell back to - react-ui's own default sizing/shape. Geometry only: background/text - color come from the generated per-person fill - (`.avatar-identity-generated`, lower specificity, so it still wins). */ +/* `Avatar`'s own root span carries `role="img"`, not a `data-slot`. */ .shell-sidebar-account-btn [role="img"] { flex-shrink: 0; width: 2.1rem; @@ -2992,48 +2987,6 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); } -/* Create-agent panel's identity row (`create-agent-panel.tsx`): a live - Avatar preview beside a row of tone swatches. Preview-only — see the - component's own doc comment on why no color is persisted today. */ -.create-agent-identity { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.create-agent-tone-row { - display: flex; - gap: 0.4rem; -} - -.create-agent-tone-swatch { - width: 1.5rem; - height: 1.5rem; - border: 2px solid transparent; - cursor: pointer; - padding: 0; -} - -.create-agent-tone-swatch[aria-pressed="true"] { - border-color: var(--foreground); -} - -.create-agent-tone-neutral { - background: var(--muted); -} - -.create-agent-tone-agent { - background: var(--primary); -} - -.create-agent-tone-agent2 { - background: var(--accent); -} - -.create-agent-tone-agent3 { - background: var(--success); -} - /* The optional "what should this agent do?" field: visually quieter than the required Name field above it — a smaller label, no bold-required asterisk, since a name alone is the supported happy path. */ diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx index afdd0c3624..b058eee921 100644 --- a/apps/web/src/pages/create-agent-panel.tsx +++ b/apps/web/src/pages/create-agent-panel.tsx @@ -39,7 +39,6 @@ // lane). import { - Avatar, Button, Dialog, DialogBody, @@ -51,8 +50,15 @@ import { IntakeForm, Textarea, } from "@corbits/react-ui"; -import type { AvatarTone, IntakeField } from "@corbits/react-ui"; +import type { IntakeField } from "@corbits/react-ui"; import { useEffect, useState } from "react"; +import { + AVATAR_COLORS, + avatarColorClass, + CORBIT_DEFAULT_COLOR, + CorbitAvatar, +} from "@corbits/chat-ui"; +import type { AvatarColor } from "@corbits/chat-ui"; import { ApiQueryError } from "@corbits/api-query"; @@ -83,23 +89,6 @@ function submitErrorFromCause( }; } -function initialsFromName(name: string): string { - const [first, second] = name.trim().split(/\s+/).filter(Boolean); - if (first === undefined) return "?"; - if (second === undefined) return first.slice(0, 2); - return `${first[0]}${second[0]}`; -} - -const AVATAR_TONES: readonly { - readonly tone: AvatarTone; - readonly label: string; -}[] = [ - { tone: "neutral", label: "Grey" }, - { tone: "agent", label: "Orange" }, - { tone: "agent2", label: "Blue" }, - { tone: "agent3", label: "Green" }, -]; - type FormValues = { readonly name: string; readonly purpose: string; @@ -248,7 +237,8 @@ export function CreateAgentPanel({ readonly onCreated: (definition: AgentDefinition) => void; }) { const [values, setValues] = useState(EMPTY_VALUES); - const [tone, setTone] = useState("agent"); + const [avatarColor, setAvatarColor] = + useState(CORBIT_DEFAULT_COLOR); const [handleTouched, setHandleTouched] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const [draftFailed, setDraftFailed] = useState(false); @@ -284,7 +274,7 @@ export function CreateAgentPanel({ function reset() { setValues(EMPTY_VALUES); - setTone("agent"); + setAvatarColor(CORBIT_DEFAULT_COLOR); setHandleTouched(false); setAdvancedOpen(false); setDraftFailed(false); @@ -437,27 +427,22 @@ export function CreateAgentPanel({

)} -
- + -
- {AVATAR_TONES.map((entry) => ( +
+ {AVATAR_COLORS.map((color) => (
diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index fc6d9170b5..a5256c5dfd 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -46,14 +46,9 @@ import { SlidersHorizontal, SquaresFour, } from "@corbits/icons"; -import type { CSSProperties } from "react"; import { useMemo } from "react"; -import { - AVATAR_IDENTITY_CLASS, - CHAT_STRINGS, - generatedAvatarStyle, -} from "@corbits/chat-ui"; +import { CHAT_STRINGS, avatarClassForPrincipal } from "@corbits/chat-ui"; import { createInsightsWindow, usageChromeLabel, @@ -278,14 +273,13 @@ export function Sidebar({ aria-label={`${user.name} · Account menu`} title={user.name} data-ctx-account="" - style={generatedAvatarStyle(user.id) as CSSProperties} > {user.name} diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index ec16cbe4ef..0491101b23 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -17,6 +17,7 @@ import { import { toast } from "@corbits/react-ui"; import { CHAT_STRINGS, + CorbitAvatar, displayWorkbenchTitle, workbenchesQueryKeyPrefix, patchWorkbenchSettings, @@ -313,11 +314,23 @@ function WorkbenchRow({ data-active={active ? "true" : undefined} onClick={onSelect} > - {/* The workbench's own initial, not its agent's — a DM and a - channel can share an agent, so an agent monogram would - collapse both rows into the same letter. */} + {/* The workbench's own initial for multi-party channels, or + the Corbit avatar for agent DM conversations. */} diff --git a/packages/chat-ui/src/avatar-identity.test.ts b/packages/chat-ui/src/avatar-identity.test.ts deleted file mode 100644 index fa87492a32..0000000000 --- a/packages/chat-ui/src/avatar-identity.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - generatedAvatarStyle, - readableTextOn, - resolveAvatarFill, -} from "./avatar-identity"; - -describe("generatedAvatarStyle", () => { - test("is deterministic for the same principal", () => { - expect(generatedAvatarStyle("prn_alice")).toEqual( - generatedAvatarStyle("prn_alice"), - ); - }); - - test("differs across distinct principals", () => { - const alice = generatedAvatarStyle("prn_alice"); - const bob = generatedAvatarStyle("prn_bob"); - expect(alice["--avatar-identity-bg"]).not.toBe(bob["--avatar-identity-bg"]); - }); - - test("never displays the seed itself", () => { - const style = generatedAvatarStyle("prn_super_secret_internal_id"); - const values = Object.values(style).join(" "); - expect(values).not.toContain("prn_super_secret_internal_id"); - }); -}); - -describe("readableTextOn", () => { - test("picks a legible label color across the full hue range", () => { - // A spread of hand-picked HSL backgrounds spanning light and dark - // lightness at the generator's fixed saturation/lightness — every - // one of them must resolve to pure black or pure white, never a - // mid-tone that would read as washed out on either. - const seeds = [ - "prn_a", - "prn_b", - "prn_c", - "prn_d", - "prn_e", - "prn_f", - "prn_g", - "prn_h", - ]; - for (const seed of seeds) { - const { "--avatar-identity-bg": bg, "--avatar-identity-fg": fg } = - generatedAvatarStyle(seed); - expect(["#000000", "#ffffff"]).toContain(fg); - expect(bg.startsWith("hsl(")).toBe(true); - } - }); - - test("is deterministic for the same background", () => { - const bg = "hsl(210 65% 45%)"; - expect(readableTextOn(bg)).toBe(readableTextOn(bg)); - }); - - test("falls back to a safe default for an unrecognized format", () => { - expect(readableTextOn("not-a-color")).toBe("#ffffff"); - }); -}); - -describe("resolveAvatarFill", () => { - test("a principal with no explicit image gets the generated fill", () => { - const fill = resolveAvatarFill("prn_alice"); - expect(fill.kind).toBe("generated"); - }); - - test("a principal with an explicit image still uses it", () => { - const fill = resolveAvatarFill("prn_alice", "https://example.com/a.png"); - expect(fill).toEqual({ - kind: "image", - url: "https://example.com/a.png", - }); - }); - - test("an empty image string is treated as no image", () => { - const fill = resolveAvatarFill("prn_alice", ""); - expect(fill.kind).toBe("generated"); - }); -}); diff --git a/packages/chat-ui/src/avatar-identity.ts b/packages/chat-ui/src/avatar-identity.ts deleted file mode 100644 index 0968e0c9c5..0000000000 --- a/packages/chat-ui/src/avatar-identity.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { colorForPrincipal } from "@corbits/presence/color"; - -/** - * A person's generated fallback fill for react-ui's `Avatar`, which has no - * `style` prop — only `className` — because its own tone system is a - * closed enum reserved for agent identity (`AvatarTone`). These two CSS - * custom properties are meant to be set on an ancestor element (they - * inherit down the DOM to the `Avatar`'s own root span, which reads them - * back through the `avatar-identity-generated` class in `app.css`) rather - * than passed as a prop react-ui doesn't accept. - */ -export type GeneratedAvatarStyle = { - readonly "--avatar-identity-bg": string; - readonly "--avatar-identity-fg": string; -}; - -/** The className that reads `GeneratedAvatarStyle`'s custom properties - * back into an actual background/text pair. Apply to the `Avatar` itself - * (or the bespoke `.chat-presence-avatar` chip); the style values belong - * on an ancestor. */ -export const AVATAR_IDENTITY_CLASS = "avatar-identity-generated"; - -const HSL_PATTERN = - /^hsl\((\d+(?:\.\d+)?) (\d+(?:\.\d+)?)% (\d+(?:\.\d+)?)%\)$/; - -function hslToRgb( - h: number, - s: number, - l: number, -): readonly [number, number, number] { - const sat = s / 100; - const light = l / 100; - const k = (n: number) => (n + h / 30) % 12; - const a = sat * Math.min(light, 1 - light); - const f = (n: number) => - light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); - return [ - Math.round(f(0) * 255), - Math.round(f(8) * 255), - Math.round(f(4) * 255), - ]; -} - -function relativeLuminance(r: number, g: number, b: number): number { - const channel = (value: number) => { - const normalized = value / 255; - return normalized <= 0.03928 - ? normalized / 12.92 - : ((normalized + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); -} - -/** - * The legible initials color (pure black or pure white — never a - * mid-tone) for a `colorForPrincipal` background, chosen by WCAG - * relative luminance so a generated avatar stays readable regardless of - * which hue the hash lands on. `colorForPrincipal` fixes saturation and - * lightness but the hue swings the whole 0-360 range, so no single fixed - * text color clears contrast for every hash. - */ -export function readableTextOn(background: string): string { - const match = HSL_PATTERN.exec(background); - if (match === null) return "#ffffff"; - const [, h, s, l] = match; - const [r, g, b] = hslToRgb(Number(h), Number(s), Number(l)); - return relativeLuminance(r, g, b) > 0.4 ? "#000000" : "#ffffff"; -} - -/** - * A stable, per-principal fill for a human's fallback avatar. Reuses - * `@corbits/presence`'s `colorForPrincipal` — the app's one existing - * deterministic-identity-color function, already shipped for live - * cursors/presence dots (CL-6328) — rather than a second hashing scheme, - * paired with a computed readable text color. Never derived from render - * order or `Math.random()`: the same `principalId` always resolves to - * the same pair, in every surface, for every viewer. - */ -export function generatedAvatarStyle( - principalId: string, -): GeneratedAvatarStyle { - const background = colorForPrincipal(principalId); - return { - "--avatar-identity-bg": background, - "--avatar-identity-fg": readableTextOn(background), - }; -} - -export type AvatarFill = - | { readonly kind: "image"; readonly url: string } - | { readonly kind: "generated"; readonly style: GeneratedAvatarStyle }; - -/** - * Which fallback an avatar should render: an explicit image always wins - * over the generated fill, when one is on hand (e.g. `UserProfile.image` - * from better-auth) — the generated look is a fallback, not a - * replacement for a real picture. - */ -export function resolveAvatarFill( - principalId: string, - explicitImageUrl?: string | null, -): AvatarFill { - if ( - explicitImageUrl !== undefined && - explicitImageUrl !== null && - explicitImageUrl.length > 0 - ) { - return { kind: "image", url: explicitImageUrl }; - } - return { kind: "generated", style: generatedAvatarStyle(principalId) }; -} diff --git a/packages/chat-ui/src/avatar.test.tsx b/packages/chat-ui/src/avatar.test.tsx new file mode 100644 index 0000000000..bb6bed6931 --- /dev/null +++ b/packages/chat-ui/src/avatar.test.tsx @@ -0,0 +1,118 @@ +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, + avatarColorForPrincipal, + resolveAvatarFill, +} from "./avatar"; + +describe("avatarColorForPrincipal", () => { + test("is deterministic for the same principal", () => { + expect(avatarColorForPrincipal("prn_alice")).toBe( + avatarColorForPrincipal("prn_alice"), + ); + }); + + test("always returns a color from the approved pastel palette", () => { + const principals = [ + "prn_alice", + "prn_bob", + "prn_carla", + "prn_dana", + "prn_eve", + "prn_frank", + ]; + for (const p of principals) { + expect(AVATAR_COLORS).toContain(avatarColorForPrincipal(p)); + } + }); + + test("distributes distinct principals across palette colors", () => { + const colors = new Set( + ["prn_alice", "prn_bob", "prn_carla", "prn_dana"].map( + avatarColorForPrincipal, + ), + ); + expect(colors.size).toBeGreaterThan(1); + }); +}); + +describe("avatarClassForPrincipal", () => { + test("is deterministic for the same principal", () => { + expect(avatarClassForPrincipal("prn_alice")).toBe( + avatarClassForPrincipal("prn_alice"), + ); + }); + + test("never displays the seed itself", () => { + expect( + avatarClassForPrincipal("prn_super_secret_internal_id"), + ).not.toContain("prn_super_secret_internal_id"); + }); +}); + +describe("resolveAvatarFill", () => { + test("a principal with no explicit image gets the generated fill", () => { + const fill = resolveAvatarFill("prn_alice"); + expect(fill.kind).toBe("generated"); + if (fill.kind === "generated") { + expect(fill.className).toBe(avatarClassForPrincipal("prn_alice")); + } + }); + + test("a principal with an explicit image still uses it", () => { + const fill = resolveAvatarFill("prn_alice", "https://example.com/a.png"); + expect(fill).toEqual({ + kind: "image", + url: "https://example.com/a.png", + }); + }); + + test("an empty image string is treated as no image", () => { + const fill = resolveAvatarFill("prn_alice", ""); + expect(fill.kind).toBe("generated"); + }); +}); + +describe("CorbitAvatar", () => { + 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("uses the selected palette color", () => { + const defaultHtml = renderToStaticMarkup(); + expect(defaultHtml).toContain(`fill="${CORBIT_DEFAULT_COLOR}"`); + + const colorHtml = renderToStaticMarkup(); + expect(colorHtml).toContain('fill="#C1D1BE"'); + }); + + 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/packages/chat-ui/src/avatar.tsx b/packages/chat-ui/src/avatar.tsx new file mode 100644 index 0000000000..258b8ac045 --- /dev/null +++ b/packages/chat-ui/src/avatar.tsx @@ -0,0 +1,123 @@ +import type { CSSProperties } from "react"; + +export const CORBIT_DEFAULT_COLOR = "#C5D2DE"; +export const AVATAR_COLORS = [ + CORBIT_DEFAULT_COLOR, + "#C1D1BE", + "#F7EAD5", + "#F2B277", +] as const; + +export type AvatarColor = (typeof AVATAR_COLORS)[number]; + +export const avatarColorClass: Record = { + "#C5D2DE": "bg-[#C5D2DE] text-black", + "#C1D1BE": "bg-[#C1D1BE] text-black", + "#F7EAD5": "bg-[#F7EAD5] text-black", + "#F2B277": "bg-[#F2B277] text-black", +}; + +export function hashPrincipal(principalId: string): number { + let hash = 0; + for (let index = 0; index < principalId.length; index += 1) { + hash = (hash * 31 + principalId.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function avatarColorForPrincipal(principalId: string): AvatarColor { + const hash = hashPrincipal(principalId); + const index = hash % AVATAR_COLORS.length; + const color = AVATAR_COLORS[index]; + if (color === undefined) { + throw new Error("Avatar color palette is empty"); + } + return color; +} + +export function avatarClassForPrincipal(principalId: string): string { + return avatarColorClass[avatarColorForPrincipal(principalId)]; +} + +export type AvatarFill = + | { readonly kind: "image"; readonly url: string } + | { readonly kind: "generated"; readonly className: string }; + +export function resolveAvatarFill( + principalId: string, + explicitImageUrl?: string | null, +): AvatarFill { + if ( + explicitImageUrl !== undefined && + explicitImageUrl !== null && + explicitImageUrl.length > 0 + ) { + return { kind: "image", url: explicitImageUrl }; + } + 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 interface CorbitAvatarProps { + readonly ariaLabel?: string; + readonly size?: CorbitAvatarSize; + readonly color?: AvatarColor; + readonly className?: string; + readonly style?: CSSProperties; +} + +const CORBIT_SIZE_CLASS = { + xs: "size-4", + sm: "size-6", + md: "size-8", + lg: "size-10", + xl: "size-20", +} as const; + +export function CorbitAvatar({ + ariaLabel = "Agent", + size = "md", + color = CORBIT_DEFAULT_COLOR, + className, + style, +}: CorbitAvatarProps) { + const sizeClass = + typeof size === "number" ? undefined : CORBIT_SIZE_CLASS[size]; + const sizeStyle: CSSProperties = + typeof size === "number" ? { width: `${size}px`, height: `${size}px` } : {}; + + return ( + + + + ); +} diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index 2cff63154a..2471129535 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -5,10 +5,11 @@ import { buildMemberAvatarStack, } from "./chat-workspace"; import type { ParticipantRecord } from "./api"; +import { avatarClassForPrincipal } from "./avatar"; import { CHAT_STRINGS } from "./strings"; -describe("buildMemberAvatarStack (CL-6594)", () => { - test("gives every agent participant its own initial and its own generated color, never a shared fallback", () => { +describe("buildMemberAvatarStack", () => { + test("identifies agent participants with agent tone for Corbit rendering", () => { const participants: readonly ParticipantRecord[] = [ { address: "run_myra@dana.localhost", handle: "myra" }, { address: "run_scout@dana.localhost", handle: "scout" }, @@ -17,18 +18,8 @@ describe("buildMemberAvatarStack (CL-6594)", () => { const stack = buildMemberAvatarStack(participants); expect(stack).toHaveLength(2); - expect(stack.map((entry) => entry.initials)).toEqual(["M", "S"]); expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Scout"]); expect(stack.every((entry) => entry.tone === "agent")).toBe(true); - - const [myra, scout] = stack; - expect(myra?.color).toBeDefined(); - expect(scout?.color).toBeDefined(); - // Distinct addresses must never collapse onto the same fallback - // fill — this is exactly what a shared CSS accent color did before - // CL-6594: two agents in one room rendered as indistinguishable - // avatars. - expect(myra?.color).not.toBe(scout?.color); }); test("is the static roster only — live presence is a separate stack", () => { @@ -58,7 +49,7 @@ describe("buildMemberAvatarStack (CL-6594)", () => { const human = stack[1]; expect(human?.key).toBe("prn_dana"); expect(human?.initials).toBe("D"); - expect(human?.color).toBeDefined(); + expect(human?.avatarClassName).toBe(avatarClassForPrincipal("prn_dana")); }); test("prefers the signed-in display name over a raw participant handle", () => { @@ -86,7 +77,6 @@ describe("buildMemberAvatarStack (CL-6594)", () => { ); expect(stack.map((entry) => entry.label)).toEqual(["Myra the Helper"]); - expect(stack.map((entry) => entry.initials)).toEqual(["M"]); }); }); diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 15beea8be9..fbe4ec0882 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -116,7 +116,7 @@ import { applyStreamReaction, useWorkbenchFeed, } from "./use-workbench-feed"; -import { generatedAvatarStyle } from "./avatar-identity"; +import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { @@ -157,16 +157,13 @@ export type TenantResolution = * One live presence entry for the workbench's who's-here stack (CL-6328) — * derived from this workbench's own `/stream` connection * (`useWorkbenchPresenceRoster`), never a second connection or an HTTP - * heartbeat poll. `displayName`/`color`/`textColor` are resolved - * client-side against the workbench's own participants and - * `generatedAvatarStyle`'s deterministic per-principal fill, since the - * roster itself carries only ids. + * heartbeat poll. The roster carries only ids, so display names and avatar + * classes resolve client-side against the workbench's participants. */ export interface PresenceMember { readonly principalId: string; readonly displayName: string; - readonly color: string; - readonly textColor: string; + readonly avatarClassName: string; } /** One entry in the header's static member stack — an agent or a roster @@ -177,8 +174,7 @@ export interface TeamAvatarEntry { readonly initials: string; readonly label: string; readonly tone: "agent" | "neutral"; - readonly color?: string; - readonly textColor?: string; + readonly avatarClassName?: string; } /** How many avatars the header shows before collapsing the rest into a @@ -213,8 +209,8 @@ const WORKBENCHES_LIST_CHROME: ChatHeaderChrome = { * signed-in human as a participant before any `chat.presence.snapshot` * arrives. Live who's-here is a separate round stack, not mixed in here. * - * Each agent gets its own `generatedAvatarStyle` fill keyed by address - * (CL-6594). Human labels prefer `currentUser.name` when the roster entry + * Each person gets a stable generated color keyed by address. Human labels + * prefer `currentUser.name` when the roster entry * is the signed-in reader — never a raw handle when a display name exists. */ export function buildMemberAvatarStack( @@ -225,24 +221,20 @@ export function buildMemberAvatarStack( const agents = participants .filter((participant) => isAgentAddress(participant.address)) .map((participant) => { - const style = generatedAvatarStyle(participant.address); const label = displayNameForAddress(participant.address, displayNames) ?? displayNameFromHandle(participant.handle); return { key: participant.address, - initials: label.slice(0, 1).toUpperCase(), + initials: "", label, tone: "agent" as const, - color: style["--avatar-identity-bg"], - textColor: style["--avatar-identity-fg"], }; }); const humans = participants .filter((participant) => !isAgentAddress(participant.address)) .map((participant) => { - const style = generatedAvatarStyle(participant.address); const label = typingLabel( localPartOf(participant.address), participants, @@ -253,8 +245,7 @@ export function buildMemberAvatarStack( initials: label.slice(0, 1).toUpperCase(), label, tone: "neutral" as const, - color: style["--avatar-identity-bg"], - textColor: style["--avatar-identity-fg"], + avatarClassName: avatarClassForPrincipal(participant.address), }; }); @@ -1341,7 +1332,6 @@ function ChatWorkspaceInner({ const presenceMembers: readonly PresenceMember[] = useMemo( () => presenceRoster.map((member) => { - const style = generatedAvatarStyle(member.principalId); return { principalId: member.principalId, displayName: typingLabel( @@ -1349,8 +1339,7 @@ function ChatWorkspaceInner({ activeWorkbench?.participants ?? [], currentUser, ), - color: style["--avatar-identity-bg"], - textColor: style["--avatar-identity-fg"], + avatarClassName: avatarClassForPrincipal(member.principalId), }; }), [presenceRoster, activeWorkbench?.participants, currentUser], @@ -1433,20 +1422,30 @@ function ChatWorkspaceInner({ className="chat-member-stack" aria-label={CHAT_STRINGS.workbenchMembersLabel} > - {visibleMemberStack.map((entry) => ( - - {entry.initials} - - ))} + {visibleMemberStack.map((entry) => + entry.tone === "agent" ? ( + + + + ) : ( + + {entry.initials} + + ), + )} {memberStackOverflow > 0 ? ( ( {member.displayName.slice(0, 1).toUpperCase()} diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx index 8a73ec5034..c4aed69065 100644 --- a/packages/chat-ui/src/composer.tsx +++ b/packages/chat-ui/src/composer.tsx @@ -24,9 +24,9 @@ import { useRef, useState, } from "react"; -import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react"; +import type { ChangeEvent, KeyboardEvent } from "react"; -import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity"; +import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; import type { Part, ParticipantRecord } from "./api"; import type { AgentDisplayNames } from "./agent-display-names"; import { @@ -1045,25 +1045,22 @@ export const Composer = forwardRef< event.preventDefault(); pickMention(option); }} - style={ - isAgent - ? undefined - : (generatedAvatarStyle( - option.candidate.id, - ) as CSSProperties) - } > - + {isAgent ? ( + + ) : ( + + )} {option.candidate.label} diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 5d8426e03e..2e360f48f1 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -20,11 +20,22 @@ export type { export { WorkbenchLoadingState } from "./loading-state"; export { - AVATAR_IDENTITY_CLASS, - generatedAvatarStyle, + CorbitAvatar, + CORBIT_DEFAULT_COLOR, + CORBIT_VISOR_COLOR, + CORBIT_GLINT_COLOR, + AVATAR_COLORS, + avatarColorClass, + avatarColorForPrincipal, + avatarClassForPrincipal, resolveAvatarFill, -} from "./avatar-identity"; -export type { AvatarFill, GeneratedAvatarStyle } from "./avatar-identity"; +} from "./avatar"; +export type { + AvatarFill, + AvatarColor, + CorbitAvatarProps, + CorbitAvatarSize, +} from "./avatar"; export { PinnedStrip } from "./pinned-strip"; export type { PinsStatus } from "./use-workbench-feed"; diff --git a/packages/chat-ui/src/pr-thread-view.tsx b/packages/chat-ui/src/pr-thread-view.tsx index eda4be0b02..73e07f7ba7 100644 --- a/packages/chat-ui/src/pr-thread-view.tsx +++ b/packages/chat-ui/src/pr-thread-view.tsx @@ -23,11 +23,10 @@ // the same shape. import { Avatar, Badge, Button } from "@corbits/react-ui"; -import type { AvatarTone, BadgeTone } from "@corbits/react-ui"; +import type { BadgeTone } from "@corbits/react-ui"; import { Fragment } from "react"; -import type { CSSProperties } from "react"; -import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity"; +import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; import { Markdown } from "./markdown"; import { CHAT_STRINGS } from "./strings"; @@ -224,25 +223,27 @@ function SuggestedFixBlock({ fix }: { readonly fix: PrThreadSuggestedFix }) { function ReplyRow({ reply }: { readonly reply: PrThreadReply }) { const isHuman = reply.role === "human"; - const avatarTone: AvatarTone = isHuman ? "neutral" : "agent"; // No stable reviewer id reaches this pure view (see the file header) — // the reviewer's own display name is already the identity this row // shows, so it doubles as the hash seed for a deterministic per-person // fill (same reviewer, same color, on every reply and every reload). return ( -
- +
+ {isHuman ? ( + + ) : ( + + )}
{reply.sender} @@ -320,11 +321,9 @@ function ThreadFooter({ footer }: { readonly footer: PrThreadFooter }) {