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. */}
- {displayTitle.slice(0, 1).toUpperCase()}
+ {workbench.kind === "chat" ? (
+
+
+
+ ) : (
+ {displayTitle.slice(0, 1).toUpperCase()}
+ )}
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("