Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,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.

**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
**Avatar identity** resolves through theme tokens, not local hex. A person without
an explicit image receives deterministic initials from the `--avatar-1` through
`--avatar-4` pastel tokens, defined once in `@corbits/chat-ui`'s stylesheet
`:root` (the proposed upstream contract for `@corbits/react-ui`'s theme).
`@corbits/chat-ui` owns the finite token-to-class map and resolves a principal
to one class pair; consumers use the resolver's classes, never their own 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.

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/create-agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,11 @@ export function CreateAgentPanel({
size="lg"
/>
<div role="group" aria-label="Agent color" className="flex gap-1.5">
{AVATAR_COLORS.map((color) => (
{AVATAR_COLORS.map((color, index) => (
<button
key={color}
type="button"
aria-label={`Choose ${color} avatar color`}
aria-label={`Choose avatar color ${index + 1}`}
aria-pressed={avatarColor === color}
className={`size-6 cursor-pointer rounded-full border-2 border-transparent p-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground aria-pressed:border-foreground ${avatarColorClass[color]}`}
disabled={submitting}
Expand Down
41 changes: 33 additions & 8 deletions packages/chat-ui/src/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CORBIT_VISOR_COLOR,
CorbitAvatar,
avatarClassForPrincipal,
avatarColorClass,
avatarColorForPrincipal,
resolveAvatarFill,
} from "./avatar";
Expand Down Expand Up @@ -57,6 +58,25 @@ describe("avatarClassForPrincipal", () => {
});
});

describe("avatar pastel tokens", () => {
test("the palette is token references, not hardcoded hex", () => {
expect(AVATAR_COLORS).toHaveLength(4);
for (const color of AVATAR_COLORS) {
expect(color.startsWith("--avatar-")).toBe(true);
expect(color).not.toContain("#");
}
});

test("every class pairs its token with the readable ink", () => {
for (const color of AVATAR_COLORS) {
const cls = avatarColorClass[color];
expect(cls).toContain(`bg-(${color})`);
expect(cls).toContain("text-black");
expect(cls).not.toContain("#");
}
});
});

describe("resolveAvatarFill", () => {
test("a principal with no explicit image gets the generated fill", () => {
const fill = resolveAvatarFill("prn_alice");
Expand All @@ -81,6 +101,19 @@ describe("resolveAvatarFill", () => {
});

describe("CorbitAvatar", () => {
test("paints its field from the identity token", () => {
const html = renderToStaticMarkup(<CorbitAvatar />);
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(<CorbitAvatar color="--avatar-3" />);
expect(html).toContain("fill:var(--avatar-3)");
});
test("renders an SVG with an accessible name and no visible label", () => {
const html = renderToStaticMarkup(
<CorbitAvatar ariaLabel="Myra" size="md" />,
Expand All @@ -93,14 +126,6 @@ describe("CorbitAvatar", () => {
expect(html).not.toContain(">Myra<");
});

test("uses the selected palette color", () => {
const defaultHtml = renderToStaticMarkup(<CorbitAvatar />);
expect(defaultHtml).toContain(`fill="${CORBIT_DEFAULT_COLOR}"`);

const colorHtml = renderToStaticMarkup(<CorbitAvatar color="#C1D1BE" />);
expect(colorHtml).toContain('fill="#C1D1BE"');
});

test("contains the visor and glint geometry", () => {
const html = renderToStaticMarkup(<CorbitAvatar />);
expect(html).toContain(`fill="${CORBIT_VISOR_COLOR}"`);
Expand Down
34 changes: 24 additions & 10 deletions packages/chat-ui/src/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import type { CSSProperties } from "react";

export const CORBIT_DEFAULT_COLOR = "#C5D2DE";
/* Avatar identity pastels (CL-7478). The palette is token references, not
hex: --avatar-1 through --avatar-4 are defined once in this package's
stylesheet :root (the proposed upstream contract for @corbits/react-ui's
theme) and consumed here by reference, so no product code hardcodes a
color value. Numbered like react-ui's --chart-1..5 series — slot order is
the deterministic resolution order, not a ranking. */
export const AVATAR_COLORS = [
CORBIT_DEFAULT_COLOR,
"#C1D1BE",
"#F7EAD5",
"#F2B277",
"--avatar-1",
"--avatar-2",
"--avatar-3",
"--avatar-4",
] as const;

export type AvatarColor = (typeof AVATAR_COLORS)[number];

export const CORBIT_DEFAULT_COLOR: AvatarColor = "--avatar-1";

export const avatarColorClass: Record<AvatarColor, string> = {
"#C5D2DE": "bg-[#C5D2DE] text-black",
"#C1D1BE": "bg-[#C1D1BE] text-black",
"#F7EAD5": "bg-[#F7EAD5] text-black",
"#F2B277": "bg-[#F2B277] text-black",
"--avatar-1": "bg-(--avatar-1) text-black",
"--avatar-2": "bg-(--avatar-2) text-black",
"--avatar-3": "bg-(--avatar-3) text-black",
"--avatar-4": "bg-(--avatar-4) text-black",
};

export function hashPrincipal(principalId: string): number {
Expand Down Expand Up @@ -111,7 +118,14 @@ export function CorbitAvatar({
className="block size-full"
aria-hidden="true"
>
<circle cx="50" cy="50" r="50" fill={color} />
<circle
cx="50"
cy="50"
r="50"
/* A presentation attribute cannot read var(), so the token
reference rides the CSS fill property instead. */
style={{ fill: `var(${color})` }}
/>
<path
d="M 11.47 59.04 C 16.17 47.15, 33.73 66.85, 45.03 65.78 C 57.11 71.28, 75.14 64.43, 83.53 71.00 C 78.24 85.08, 58.92 92.65, 44.56 89.83 C 28.55 87.40, 15.10 75.30, 11.47 59.49 Z"
fill={CORBIT_VISOR_COLOR}
Expand Down
10 changes: 10 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
its own. Falls back to the equivalent cubic-bezier only for the rare
test/story context that doesn't load react-ui's theme stylesheet. */
--chat-ease: var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1));

/* Avatar identity pastels: the single definition of the deterministic
principal palette (CL-7478). Numbered like react-ui's --chart-1..5
series — slot order is the resolution order, not a ranking. These four
names are the proposed upstream contract: when @corbits/react-ui's
theme ships them natively, delete this block and consume them there. */
--avatar-1: #c5d2de;
--avatar-2: #c1d1be;
--avatar-3: #f7ead5;
--avatar-4: #f2b277;
}

.chat-workspace {
Expand Down
Loading