Skip to content

Commit ae4580f

Browse files
feat(chat-ui): resolve avatar identity through theme tokens (#715)
* test(chat-ui): avatar palette resolves through theme tokens * feat(chat-ui): resolve avatar identity through theme tokens Avatar pastels move from hardcoded hex to --avatar-1..4 tokens defined once in chat-ui's stylesheet :root — the proposed upstream contract for react-ui's theme. Class map, CorbitAvatar field, and the create-agent swatches all consume the tokens by reference; DESIGN.md's color-exception section now describes the token rule. No hex remains in the identity path. Fixes CL-7478
1 parent e1ac0ae commit ae4580f

5 files changed

Lines changed: 76 additions & 25 deletions

File tree

‎DESIGN.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,13 @@ and the rest of its semantic palette. Never hardcode a hex value or an
163163
arbitrary Tailwind color class in product code; if a needed token doesn't
164164
exist yet, add it in react-ui, not locally.
165165

166-
**Avatar identity** is the one deliberate color exception. A person without
167-
an explicit image receives deterministic initials from the approved pastel
168-
palette. `@corbits/chat-ui` owns the finite Tailwind background/text class
169-
map and resolves a principal to one class pair; consumers never use CSS
170-
variables or inline color styles. Agents use `CorbitAvatar`: a circular
166+
**Avatar identity** resolves through theme tokens, not local hex. A person without
167+
an explicit image receives deterministic initials from the `--avatar-1` through
168+
`--avatar-4` pastel tokens, defined once in `@corbits/chat-ui`'s stylesheet
169+
`:root` (the proposed upstream contract for `@corbits/react-ui`'s theme).
170+
`@corbits/chat-ui` owns the finite token-to-class map and resolves a principal
171+
to one class pair; consumers use the resolver's classes, never their own color
172+
styles. Agents use `CorbitAvatar`: a circular
171173
field with a dark lower visor and sensor glint. The two forms stay distinct
172174
at a glance in every collaborative surface.
173175

‎apps/web/src/pages/create-agent-panel.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,11 +434,11 @@ export function CreateAgentPanel({
434434
size="lg"
435435
/>
436436
<div role="group" aria-label="Agent color" className="flex gap-1.5">
437-
{AVATAR_COLORS.map((color) => (
437+
{AVATAR_COLORS.map((color, index) => (
438438
<button
439439
key={color}
440440
type="button"
441-
aria-label={`Choose ${color} avatar color`}
441+
aria-label={`Choose avatar color ${index + 1}`}
442442
aria-pressed={avatarColor === color}
443443
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]}`}
444444
disabled={submitting}

‎packages/chat-ui/src/avatar.test.tsx‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
CORBIT_VISOR_COLOR,
99
CorbitAvatar,
1010
avatarClassForPrincipal,
11+
avatarColorClass,
1112
avatarColorForPrincipal,
1213
resolveAvatarFill,
1314
} from "./avatar";
@@ -57,6 +58,25 @@ describe("avatarClassForPrincipal", () => {
5758
});
5859
});
5960

61+
describe("avatar pastel tokens", () => {
62+
test("the palette is token references, not hardcoded hex", () => {
63+
expect(AVATAR_COLORS).toHaveLength(4);
64+
for (const color of AVATAR_COLORS) {
65+
expect(color.startsWith("--avatar-")).toBe(true);
66+
expect(color).not.toContain("#");
67+
}
68+
});
69+
70+
test("every class pairs its token with the readable ink", () => {
71+
for (const color of AVATAR_COLORS) {
72+
const cls = avatarColorClass[color];
73+
expect(cls).toContain(`bg-(${color})`);
74+
expect(cls).toContain("text-black");
75+
expect(cls).not.toContain("#");
76+
}
77+
});
78+
});
79+
6080
describe("resolveAvatarFill", () => {
6181
test("a principal with no explicit image gets the generated fill", () => {
6282
const fill = resolveAvatarFill("prn_alice");
@@ -81,6 +101,19 @@ describe("resolveAvatarFill", () => {
81101
});
82102

83103
describe("CorbitAvatar", () => {
104+
test("paints its field from the identity token", () => {
105+
const html = renderToStaticMarkup(<CorbitAvatar />);
106+
expect(html).toContain(`fill:var(${CORBIT_DEFAULT_COLOR})`);
107+
// The field's old hardcoded fill is gone. The shared visor/glint face
108+
// geometry keeps its fixed constants — it is identical on every agent,
109+
// so it was never part of the per-principal identity palette.
110+
expect(html).not.toContain('fill="#C5D2DE"');
111+
});
112+
113+
test("a chosen palette token paints the field", () => {
114+
const html = renderToStaticMarkup(<CorbitAvatar color="--avatar-3" />);
115+
expect(html).toContain("fill:var(--avatar-3)");
116+
});
84117
test("renders an SVG with an accessible name and no visible label", () => {
85118
const html = renderToStaticMarkup(
86119
<CorbitAvatar ariaLabel="Myra" size="md" />,
@@ -93,14 +126,6 @@ describe("CorbitAvatar", () => {
93126
expect(html).not.toContain(">Myra<");
94127
});
95128

96-
test("uses the selected palette color", () => {
97-
const defaultHtml = renderToStaticMarkup(<CorbitAvatar />);
98-
expect(defaultHtml).toContain(`fill="${CORBIT_DEFAULT_COLOR}"`);
99-
100-
const colorHtml = renderToStaticMarkup(<CorbitAvatar color="#C1D1BE" />);
101-
expect(colorHtml).toContain('fill="#C1D1BE"');
102-
});
103-
104129
test("contains the visor and glint geometry", () => {
105130
const html = renderToStaticMarkup(<CorbitAvatar />);
106131
expect(html).toContain(`fill="${CORBIT_VISOR_COLOR}"`);

‎packages/chat-ui/src/avatar.tsx‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import type { CSSProperties } from "react";
22

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

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

18+
export const CORBIT_DEFAULT_COLOR: AvatarColor = "--avatar-1";
19+
1320
export const avatarColorClass: Record<AvatarColor, string> = {
14-
"#C5D2DE": "bg-[#C5D2DE] text-black",
15-
"#C1D1BE": "bg-[#C1D1BE] text-black",
16-
"#F7EAD5": "bg-[#F7EAD5] text-black",
17-
"#F2B277": "bg-[#F2B277] text-black",
21+
"--avatar-1": "bg-(--avatar-1) text-black",
22+
"--avatar-2": "bg-(--avatar-2) text-black",
23+
"--avatar-3": "bg-(--avatar-3) text-black",
24+
"--avatar-4": "bg-(--avatar-4) text-black",
1825
};
1926

2027
export function hashPrincipal(principalId: string): number {
@@ -111,7 +118,14 @@ export function CorbitAvatar({
111118
className="block size-full"
112119
aria-hidden="true"
113120
>
114-
<circle cx="50" cy="50" r="50" fill={color} />
121+
<circle
122+
cx="50"
123+
cy="50"
124+
r="50"
125+
/* A presentation attribute cannot read var(), so the token
126+
reference rides the CSS fill property instead. */
127+
style={{ fill: `var(${color})` }}
128+
/>
115129
<path
116130
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"
117131
fill={CORBIT_VISOR_COLOR}

‎packages/chat-ui/src/styles.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
1616
its own. Falls back to the equivalent cubic-bezier only for the rare
1717
test/story context that doesn't load react-ui's theme stylesheet. */
1818
--chat-ease: var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1));
19+
20+
/* Avatar identity pastels: the single definition of the deterministic
21+
principal palette (CL-7478). Numbered like react-ui's --chart-1..5
22+
series — slot order is the resolution order, not a ranking. These four
23+
names are the proposed upstream contract: when @corbits/react-ui's
24+
theme ships them natively, delete this block and consume them there. */
25+
--avatar-1: #c5d2de;
26+
--avatar-2: #c1d1be;
27+
--avatar-3: #f7ead5;
28+
--avatar-4: #f2b277;
1929
}
2030

2131
.chat-workspace {

0 commit comments

Comments
 (0)