From 8df376857c76125236e3d75840ae2aef6ef28a92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 11:11:37 +0000 Subject: [PATCH 01/13] Add tests for Corbit agent avatar and pastel human initials fallback --- packages/chat-ui/src/avatar-identity.test.ts | 82 +++++++++++------ packages/chat-ui/src/chat-workspace.test.ts | 13 +-- packages/chat-ui/src/corbit-avatar.test.tsx | 61 +++++++++++++ .../chat-ui/test/avatar-composition.test.tsx | 90 +++++++++++++++++++ packages/chat-ui/test/chat-workspace.test.tsx | 2 +- .../chat-ui/test/team-avatar-stack.test.tsx | 28 +++--- 6 files changed, 220 insertions(+), 56 deletions(-) create mode 100644 packages/chat-ui/src/corbit-avatar.test.tsx create mode 100644 packages/chat-ui/test/avatar-composition.test.tsx diff --git a/packages/chat-ui/src/avatar-identity.test.ts b/packages/chat-ui/src/avatar-identity.test.ts index fa87492a32..b1b9cfdc67 100644 --- a/packages/chat-ui/src/avatar-identity.test.ts +++ b/packages/chat-ui/src/avatar-identity.test.ts @@ -2,10 +2,43 @@ import { describe, expect, test } from "bun:test"; import { generatedAvatarStyle, + pastelColorForPrincipal, readableTextOn, resolveAvatarFill, + PASTEL_PALETTE, } from "./avatar-identity"; +describe("pastelColorForPrincipal", () => { + test("is deterministic for the same principal", () => { + expect(pastelColorForPrincipal("prn_alice")).toBe( + pastelColorForPrincipal("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(PASTEL_PALETTE).toContain(pastelColorForPrincipal(p)); + } + }); + + test("distributes distinct principals across palette colors", () => { + const colors = new Set( + ["prn_alice", "prn_bob", "prn_carla", "prn_dana"].map( + pastelColorForPrincipal, + ), + ); + expect(colors.size).toBeGreaterThan(1); + }); +}); + describe("generatedAvatarStyle", () => { test("is deterministic for the same principal", () => { expect(generatedAvatarStyle("prn_alice")).toEqual( @@ -13,10 +46,12 @@ describe("generatedAvatarStyle", () => { ); }); - 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("uses pastel palette background and legible foreground", () => { + const style = generatedAvatarStyle("prn_alice"); + expect(PASTEL_PALETTE).toContain( + style["--avatar-identity-bg"] as (typeof PASTEL_PALETTE)[number], + ); + expect(["#000000", "#ffffff"]).toContain(style["--avatar-identity-fg"]); }); test("never displays the seed itself", () => { @@ -27,36 +62,26 @@ describe("generatedAvatarStyle", () => { }); 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("picks a legible label color across the pastel palette", () => { + for (const color of PASTEL_PALETTE) { + expect(readableTextOn(color)).toBe("#000000"); } }); + test("supports both hex and hsl colors", () => { + expect(readableTextOn("#FFFFFF")).toBe("#000000"); + expect(readableTextOn("#000000")).toBe("#ffffff"); + expect(readableTextOn("hsl(0 0% 100%)")).toBe("#000000"); + expect(readableTextOn("hsl(0 0% 0%)")).toBe("#ffffff"); + }); + test("is deterministic for the same background", () => { - const bg = "hsl(210 65% 45%)"; + const bg = "#C5D2DE"; expect(readableTextOn(bg)).toBe(readableTextOn(bg)); }); test("falls back to a safe default for an unrecognized format", () => { - expect(readableTextOn("not-a-color")).toBe("#ffffff"); + expect(readableTextOn("not-a-color")).toBe("#000000"); }); }); @@ -64,6 +89,11 @@ 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(PASTEL_PALETTE).toContain( + fill.style["--avatar-identity-bg"] as (typeof PASTEL_PALETTE)[number], + ); + } }); test("a principal with an explicit image still uses it", () => { diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index 2cff63154a..6c1021ed28 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -7,8 +7,8 @@ import { import type { ParticipantRecord } from "./api"; 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" }, @@ -20,15 +20,6 @@ describe("buildMemberAvatarStack (CL-6594)", () => { 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", () => { diff --git a/packages/chat-ui/src/corbit-avatar.test.tsx b/packages/chat-ui/src/corbit-avatar.test.tsx new file mode 100644 index 0000000000..54f35346dc --- /dev/null +++ b/packages/chat-ui/src/corbit-avatar.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CorbitAvatar, + CORBIT_DEFAULT_BACKGROUND, + CORBIT_GLINT_COLOR, + CORBIT_VISOR_COLOR, +} from "./corbit-avatar"; + +describe("CorbitAvatar", () => { + test("renders an SVG avatar with role img and accessible label", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="Myra"'); + expect(html).toContain('data-corbit="true"'); + expect(html).toContain(" { + const defaultHtml = renderToStaticMarkup(); + expect(defaultHtml).toContain(`fill="${CORBIT_DEFAULT_BACKGROUND}"`); + + const customHtml = renderToStaticMarkup( + , + ); + expect(customHtml).toContain('fill="#C1D1BE"'); + }); + + test("contains the distinct visor and sensor glint geometries", () => { + const html = renderToStaticMarkup(); + expect(html).toContain(`fill="${CORBIT_VISOR_COLOR}"`); + expect(html).toContain(`fill="${CORBIT_GLINT_COLOR}"`); + }); + + test("supports size tokens and numeric dimensions", () => { + const smHtml = renderToStaticMarkup( + , + ); + expect(smHtml).toContain("size-6"); + + const lgHtml = renderToStaticMarkup( + , + ); + expect(lgHtml).toContain("size-10"); + + const customSizeHtml = renderToStaticMarkup( + , + ); + expect(customSizeHtml).toContain("width:28px"); + expect(customSizeHtml).toContain("height:28px"); + }); + + test("renders optional tenant monogram badge when provided", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("A"); + }); +}); diff --git a/packages/chat-ui/test/avatar-composition.test.tsx b/packages/chat-ui/test/avatar-composition.test.tsx new file mode 100644 index 0000000000..fa5a557a9e --- /dev/null +++ b/packages/chat-ui/test/avatar-composition.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CorbitAvatar, + CORBIT_DEFAULT_BACKGROUND, + PASTEL_PALETTE, + generatedAvatarStyle, + pastelColorForPrincipal, + resolveAvatarFill, +} from "../src"; + +describe("avatar composition and identity resolution", () => { + test("agent Corbit avatar is distinct from human initials avatar", () => { + const corbitHtml = renderToStaticMarkup( + , + ); + // Agent Corbit has SVG geometry, role=img, data-corbit, and no initials text node + expect(corbitHtml).toContain('data-corbit="true"'); + expect(corbitHtml).toContain('aria-label="Echo Agent"'); + expect(corbitHtml).toContain("EA<"); + }); + + test("people without photos receive deterministic light pastel initials fallback", () => { + const aliceColor = pastelColorForPrincipal("usr_alice"); + const bobColor = pastelColorForPrincipal("usr_bob"); + + expect(PASTEL_PALETTE).toContain(aliceColor); + expect(PASTEL_PALETTE).toContain(bobColor); + + const aliceStyle = generatedAvatarStyle("usr_alice"); + expect(aliceStyle["--avatar-identity-bg"]).toBe(aliceColor); + expect(aliceStyle["--avatar-identity-fg"]).toBe("#000000"); + + // Same user always gets the exact same pastel color + expect(pastelColorForPrincipal("usr_alice")).toBe(aliceColor); + }); + + test("human explicit image takes precedence over pastel initials fallback", () => { + const withImage = resolveAvatarFill( + "usr_alice", + "https://example.com/avatar.jpg", + ); + expect(withImage.kind).toBe("image"); + if (withImage.kind === "image") { + expect(withImage.url).toBe("https://example.com/avatar.jpg"); + } + + const withoutImage = resolveAvatarFill("usr_alice", null); + expect(withoutImage.kind).toBe("generated"); + if (withoutImage.kind === "generated") { + expect(PASTEL_PALETTE).toContain( + withoutImage.style[ + "--avatar-identity-bg" + ] as (typeof PASTEL_PALETTE)[number], + ); + } + }); + + test("dense avatar context: multiple agents and humans remain visually distinct", () => { + const participants = [ + { id: "agent_myra", name: "Myra", isAgent: true }, + { id: "agent_scout", name: "Scout", isAgent: true }, + { id: "user_carla", name: "Carla", isAgent: false }, + { id: "user_dana", name: "Dana", isAgent: false }, + ]; + + const rendered = participants.map((p) => { + if (p.isAgent) { + return renderToStaticMarkup(); + } + const style = generatedAvatarStyle(p.id); + return renderToStaticMarkup( + + {p.name.slice(0, 1)} + , + ); + }); + + // Agents have data-corbit and svg, humans have initials and pastel style + expect(rendered[0]).toContain('data-corbit="true"'); + expect(rendered[1]).toContain('data-corbit="true"'); + expect(rendered[2]).toContain('data-testid="human-avatar"'); + expect(rendered[2]).toContain("C"); + expect(rendered[3]).toContain('data-testid="human-avatar"'); + expect(rendered[3]).toContain("D"); + }); +}); diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 2d7cf2e7bf..177910afbd 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -2020,7 +2020,7 @@ describe("Workbench header polish (CL-6106)", () => { ); expect(chip).not.toBeNull(); expect((chip as HTMLElement).title).toBe("Researcher"); - expect((chip as HTMLElement).textContent).toBe("R"); + expect(chip?.querySelector('[data-corbit="true"]')).not.toBeNull(); harness.unmount(); }); diff --git a/packages/chat-ui/test/team-avatar-stack.test.tsx b/packages/chat-ui/test/team-avatar-stack.test.tsx index e9d0c42cb0..f10ac23ced 100644 --- a/packages/chat-ui/test/team-avatar-stack.test.tsx +++ b/packages/chat-ui/test/team-avatar-stack.test.tsx @@ -170,10 +170,10 @@ describe("workbench header member avatar stack", () => { expect(agentAvatars).toHaveLength(1); const agentAvatar = agentAvatars[0] as HTMLElement; expect(agentAvatar.title).toBe("Myra"); - expect(agentAvatar.textContent).toBe("M"); const memberHumans = harness.container.querySelectorAll( ".chat-member-avatar:not([data-agent])", ); + expect(agentAvatar.querySelector('[data-corbit="true"]')).not.toBeNull(); expect(memberHumans).toHaveLength(1); expect((memberHumans[0] as HTMLElement).title).toBe("Alice"); const liveStack = harness.container.querySelector(".chat-presence-stack"); @@ -189,7 +189,7 @@ describe("workbench header member avatar stack", () => { harness.unmount(); }); - test("gives every agent its own initial and color, never a shared fallback (CL-6594)", async () => { + test("renders distinct Corbit avatars for agent participants", async () => { stubFetch({ participants: [ { address: "run_myra@dana.localhost", handle: "myra" }, @@ -208,23 +208,15 @@ describe("workbench header member avatar stack", () => { ), ) as HTMLElement[]; expect(agentAvatars).toHaveLength(2); - expect(agentAvatars.map((avatar) => avatar.textContent)).toEqual([ - "M", - "S", + expect(agentAvatars.map((avatar) => avatar.title)).toEqual([ + "Myra", + "Scout", ]); - // Each agent avatar carries its own `AVATAR_IDENTITY_CLASS`-free - // inline text color (the CSS custom-property indirection this - // package uses elsewhere doesn't apply to this chip), proving two - // agents never render with the exact same computed fill — the - // per-address color itself is `buildMemberAvatarStack`'s own unit - // test (`../src/chat-workspace.test.ts`), since happy-dom drops the - // space-syntax `hsl()` `colorForPrincipal` emits before it reaches - // any DOM assertion here. - const [myraText, scoutText] = agentAvatars.map( - (avatar) => avatar.style.color, - ); - expect(myraText).not.toBe(""); - expect(scoutText).not.toBe(""); + expect( + agentAvatars.every( + (avatar) => avatar.querySelector('[data-corbit="true"]') !== null, + ), + ).toBe(true); harness.unmount(); }); From 54fbb0923ed1ef9838f404e698d661234842950b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 11:11:44 +0000 Subject: [PATCH 02/13] Add Corbit agent avatar and light pastel fallback for people without photos --- apps/web/src/app.css | 15 +++ apps/web/src/pages/create-agent-panel.tsx | 43 ++++---- apps/web/src/shell/workbench-list.tsx | 14 ++- packages/chat-ui/src/avatar-identity.ts | 90 ++++++++++++---- packages/chat-ui/src/chat-workspace.tsx | 44 ++++---- packages/chat-ui/src/composer.tsx | 27 +++-- packages/chat-ui/src/corbit-avatar.tsx | 101 ++++++++++++++++++ packages/chat-ui/src/index.ts | 17 ++- packages/chat-ui/src/pr-thread-view.tsx | 34 +++--- packages/chat-ui/src/timeline.tsx | 58 ++++++---- .../src/workbench-settings/agents-section.tsx | 6 +- 11 files changed, 337 insertions(+), 112 deletions(-) create mode 100644 packages/chat-ui/src/corbit-avatar.tsx diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5721abcb9f..addcf7c714 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1566,6 +1566,20 @@ select:disabled, color: var(--primary-foreground); } +.shell-ch-stack span[data-agent="true"].shell-ch-corbit-wrap { + padding: 0; + overflow: hidden; + background: transparent; + border: 1.5px solid var(--background); +} + +.shell-ch-stack + span[data-agent="true"].shell-ch-corbit-wrap + [data-corbit="true"] { + width: 100%; + height: 100%; +} + /* Agent-DM identity avatar: zero radius per brand rule, distinct from the round workbench-participant stack above — a small, deterministic, brand-compatible palette hashed off the agent's own name (see @@ -3010,6 +3024,7 @@ tr.insights-row-clickable:hover { .create-agent-tone-swatch { width: 1.5rem; height: 1.5rem; + border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx index afdd0c3624..406173fb6f 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,9 @@ 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 { CorbitAvatar } from "@corbits/chat-ui"; import { ApiQueryError } from "@corbits/api-query"; @@ -83,21 +83,16 @@ 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 DEFAULT_CORBIT_COLOR = "#C5D2DE"; -const AVATAR_TONES: readonly { - readonly tone: AvatarTone; +const CORBIT_PALETTE_SWATCHES: readonly { + readonly color: string; readonly label: string; }[] = [ - { tone: "neutral", label: "Grey" }, - { tone: "agent", label: "Orange" }, - { tone: "agent2", label: "Blue" }, - { tone: "agent3", label: "Green" }, + { color: "#C5D2DE", label: "Summit Blue" }, + { color: "#C1D1BE", label: "Ridge Green" }, + { color: "#F7EAD5", label: "Canvas Cream" }, + { color: "#F2B277", label: "Breakthrough Orange" }, ]; type FormValues = { @@ -248,7 +243,7 @@ export function CreateAgentPanel({ readonly onCreated: (definition: AgentDefinition) => void; }) { const [values, setValues] = useState(EMPTY_VALUES); - const [tone, setTone] = useState("agent"); + const [avatarBg, setAvatarBg] = useState(DEFAULT_CORBIT_COLOR); const [handleTouched, setHandleTouched] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const [draftFailed, setDraftFailed] = useState(false); @@ -284,7 +279,7 @@ export function CreateAgentPanel({ function reset() { setValues(EMPTY_VALUES); - setTone("agent"); + setAvatarBg(DEFAULT_CORBIT_COLOR); setHandleTouched(false); setAdvancedOpen(false); setDraftFailed(false); @@ -438,10 +433,9 @@ export function CreateAgentPanel({ )}
-
- {AVATAR_TONES.map((entry) => ( + {CORBIT_PALETTE_SWATCHES.map((entry) => (
diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index ec16cbe4ef..1636850d37 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,16 @@ 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.ts b/packages/chat-ui/src/avatar-identity.ts index 0968e0c9c5..8d077fbcbf 100644 --- a/packages/chat-ui/src/avatar-identity.ts +++ b/packages/chat-ui/src/avatar-identity.ts @@ -1,4 +1,18 @@ -import { colorForPrincipal } from "@corbits/presence/color"; +/** + * Approved Corbits pastel palette for avatars: + * 1. Summit Blue: #C5D2DE + * 2. Ridge Green: #C1D1BE + * 3. Canvas Cream: #F7EAD5 + * 4. Breakthrough Orange: #F2B277 + */ +export const PASTEL_PALETTE = [ + "#C5D2DE", // Summit Blue + "#C1D1BE", // Ridge Green + "#F7EAD5", // Canvas Cream + "#F2B277", // Breakthrough Orange +] as const; + +export type PastelColor = (typeof PASTEL_PALETTE)[number]; /** * A person's generated fallback fill for react-ui's `Avatar`, which has no @@ -20,6 +34,7 @@ export type GeneratedAvatarStyle = { * on an ancestor. */ export const AVATAR_IDENTITY_CLASS = "avatar-identity-generated"; +const HEX_PATTERN = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; const HSL_PATTERN = /^hsl\((\d+(?:\.\d+)?) (\d+(?:\.\d+)?)% (\d+(?:\.\d+)?)%\)$/; @@ -41,6 +56,30 @@ function hslToRgb( ]; } +function parseColorToRgb( + color: string, +): readonly [number, number, number] | null { + const hexMatch = HEX_PATTERN.exec(color); + if (hexMatch !== null) { + const r = hexMatch[1]; + const g = hexMatch[2]; + const b = hexMatch[3]; + if (r !== undefined && g !== undefined && b !== undefined) { + return [ + Number.parseInt(r, 16), + Number.parseInt(g, 16), + Number.parseInt(b, 16), + ]; + } + } + const hslMatch = HSL_PATTERN.exec(color); + if (hslMatch !== null) { + const [, h, s, l] = hslMatch; + return hslToRgb(Number(h), Number(s), Number(l)); + } + return null; +} + function relativeLuminance(r: number, g: number, b: number): number { const channel = (value: number) => { const normalized = value / 255; @@ -53,33 +92,48 @@ function relativeLuminance(r: number, g: number, b: number): number { /** * 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. + * mid-tone) for a background, chosen by WCAG relative luminance so a + * generated avatar stays readable. */ 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)); + const rgb = parseColorToRgb(background); + if (rgb === null) return "#000000"; + const [r, g, b] = rgb; 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. + * Deterministic hash of a principal ID string. + */ +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; +} + +/** + * Stable, per-person light pastel background fill from the approved + * Corbits palette. Never swings arbitrary saturated hues. + */ +export function pastelColorForPrincipal(principalId: string): PastelColor { + const hash = hashPrincipal(principalId); + const index = hash % PASTEL_PALETTE.length; + return PASTEL_PALETTE[index] ?? PASTEL_PALETTE[0]; +} + +/** + * A stable, per-principal fill for a human's fallback avatar. + * Uses a deterministic selection from the Workbench light-pastel palette + * paired with a computed readable text color. + * 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); + const background = pastelColorForPrincipal(principalId); return { "--avatar-identity-bg": background, "--avatar-identity-fg": readableTextOn(background), diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 15beea8be9..042f6b47d2 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -117,6 +117,7 @@ import { useWorkbenchFeed, } from "./use-workbench-feed"; import { generatedAvatarStyle } from "./avatar-identity"; +import { CorbitAvatar } from "./corbit-avatar"; import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { @@ -225,17 +226,14 @@ 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"], }; }); @@ -1433,20 +1431,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 ? ( - + {isAgent ? ( + + ) : ( + + )} {option.candidate.label} diff --git a/packages/chat-ui/src/corbit-avatar.tsx b/packages/chat-ui/src/corbit-avatar.tsx new file mode 100644 index 0000000000..0cc7254b90 --- /dev/null +++ b/packages/chat-ui/src/corbit-avatar.tsx @@ -0,0 +1,101 @@ +import type { CSSProperties } from "react"; + +export const CORBIT_DEFAULT_BACKGROUND = "#C5D2DE"; // Summit Blue +export const CORBIT_VISOR_COLOR = "#22252A"; +export const CORBIT_GLINT_COLOR = "#F7EAD5"; // Canvas Cream + +export type CorbitAvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | number; + +export interface CorbitAvatarProps { + readonly label?: string; + readonly size?: CorbitAvatarSize; + readonly background?: string; + readonly className?: string; + readonly style?: CSSProperties; + readonly tenantMonogram?: string; + readonly title?: string; +} + +const SIZE_CLASS: Record = { + xs: "size-4", // 16px + sm: "size-6", // 24px + md: "size-8", // 32px + lg: "size-10", // 40px + xl: "size-20", // 80px +}; + +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]", + xl: "size-5 text-[11px]", +}; + +export function CorbitAvatar({ + label = "Agent", + size = "md", + background = CORBIT_DEFAULT_BACKGROUND, + className, + style, + tenantMonogram, + title, +}: CorbitAvatarProps) { + const isNamedSize = typeof size === "string"; + const sizeClass = isNamedSize + ? (SIZE_CLASS[size] ?? SIZE_CLASS.md) + : undefined; + const badgeClass = isNamedSize + ? (BADGE_SIZE[size] ?? BADGE_SIZE.md) + : BADGE_SIZE.md; + + const dimensionStyle: CSSProperties = + typeof size === "number" ? { width: `${size}px`, height: `${size}px` } : {}; + + const combinedStyle: CSSProperties = { + ...dimensionStyle, + ...style, + }; + + const combinedClass = [ + "relative inline-flex shrink-0 items-center justify-center rounded-full overflow-hidden select-none", + sizeClass, + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + + {tenantMonogram !== undefined ? ( + + ) : null} + + ); +} diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 5d8426e03e..0b5a2301c4 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -19,12 +19,27 @@ export type { export { WorkbenchLoadingState } from "./loading-state"; +export { + CorbitAvatar, + CORBIT_DEFAULT_BACKGROUND, + CORBIT_VISOR_COLOR, + CORBIT_GLINT_COLOR, +} from "./corbit-avatar"; +export type { CorbitAvatarProps, CorbitAvatarSize } from "./corbit-avatar"; + export { AVATAR_IDENTITY_CLASS, + PASTEL_PALETTE, generatedAvatarStyle, + pastelColorForPrincipal, + readableTextOn, resolveAvatarFill, } from "./avatar-identity"; -export type { AvatarFill, GeneratedAvatarStyle } from "./avatar-identity"; +export type { + AvatarFill, + GeneratedAvatarStyle, + PastelColor, +} from "./avatar-identity"; 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..526c0c3f4d 100644 --- a/packages/chat-ui/src/pr-thread-view.tsx +++ b/packages/chat-ui/src/pr-thread-view.tsx @@ -23,11 +23,12 @@ // 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 } from "./corbit-avatar"; import { Markdown } from "./markdown"; import { CHAT_STRINGS } from "./strings"; @@ -224,7 +225,6 @@ 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 @@ -236,13 +236,21 @@ function ReplyRow({ reply }: { readonly reply: PrThreadReply }) { ? { style: generatedAvatarStyle(reply.sender) as CSSProperties } : {})} > - + {isHuman ? ( + + ) : ( + + )}
{reply.sender} @@ -320,13 +328,7 @@ function ThreadFooter({ footer }: { readonly footer: PrThreadFooter }) {