Skip to content
Closed
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
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ 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
palette. `@corbits/chat-ui` owns the finite Tailwind color palette 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.
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/pages/create-agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ import {
import type { IntakeField } from "@corbits/react-ui";
import { useEffect, useState } from "react";
import {
AVATAR_COLORS,
avatarColorClass,
AVATAR_PALETTE,
CORBIT_DEFAULT_COLOR,
CorbitAvatar,
} from "@corbits/chat-ui";
Expand Down Expand Up @@ -434,13 +433,13 @@ export function CreateAgentPanel({
size="lg"
/>
<div role="group" aria-label="Agent color" className="flex gap-1.5">
{AVATAR_COLORS.map((color) => (
{AVATAR_PALETTE.map(({ color, className }) => (
<button
key={color}
type="button"
aria-label={`Choose ${color} avatar color`}
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]}`}
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 ${className}`}
disabled={submitting}
onClick={() => setAvatarColor(color)}
/>
Expand Down
11 changes: 9 additions & 2 deletions packages/chat-ui/src/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";

import {
AVATAR_COLORS,
AVATAR_PALETTE,
CORBIT_DEFAULT_COLOR,
CORBIT_GLINT_COLOR,
CORBIT_VISOR_COLOR,
Expand All @@ -29,7 +29,9 @@ describe("avatarColorForPrincipal", () => {
"prn_frank",
];
for (const p of principals) {
expect(AVATAR_COLORS).toContain(avatarColorForPrincipal(p));
expect(AVATAR_PALETTE.map((entry) => entry.color)).toContain(
avatarColorForPrincipal(p),
);
}
});

Expand All @@ -55,6 +57,11 @@ describe("avatarClassForPrincipal", () => {
avatarClassForPrincipal("prn_super_secret_internal_id"),
).not.toContain("prn_super_secret_internal_id");
});

test("selects one of the palette's Tailwind class pairs", () => {
const paletteClassNames = AVATAR_PALETTE.map((entry) => entry.className);
expect(paletteClassNames).toContain(avatarClassForPrincipal("prn_alice"));
});
});

describe("resolveAvatarFill", () => {
Expand Down
35 changes: 16 additions & 19 deletions packages/chat-ui/src/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import type { CSSProperties } from "react";

export const CORBIT_DEFAULT_COLOR = "#C5D2DE";
export const AVATAR_COLORS = [
CORBIT_DEFAULT_COLOR,
"#C1D1BE",
"#F7EAD5",
"#F2B277",
export const AVATAR_PALETTE = [
{ color: CORBIT_DEFAULT_COLOR, className: "bg-[#C5D2DE] text-black" },
{ color: "#C1D1BE", className: "bg-[#C1D1BE] text-black" },
{ color: "#F7EAD5", className: "bg-[#F7EAD5] text-black" },
{ color: "#F2B277", className: "bg-[#F2B277] text-black" },
] as const;

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

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",
};
export type AvatarColor = (typeof AVATAR_PALETTE)[number]["color"];

export function hashPrincipal(principalId: string): number {
let hash = 0;
Expand All @@ -25,18 +18,22 @@ export function hashPrincipal(principalId: string): number {
return hash;
}

export function avatarColorForPrincipal(principalId: string): AvatarColor {
function avatarPaletteEntryForPrincipal(principalId: string) {
const hash = hashPrincipal(principalId);
const index = hash % AVATAR_COLORS.length;
const color = AVATAR_COLORS[index];
if (color === undefined) {
const index = hash % AVATAR_PALETTE.length;
const entry = AVATAR_PALETTE[index];
if (entry === undefined) {
throw new Error("Avatar color palette is empty");
}
return color;
return entry;
}

export function avatarColorForPrincipal(principalId: string): AvatarColor {
return avatarPaletteEntryForPrincipal(principalId).color;
}

export function avatarClassForPrincipal(principalId: string): string {
return avatarColorClass[avatarColorForPrincipal(principalId)];
return avatarPaletteEntryForPrincipal(principalId).className;
}

export type AvatarFill =
Expand Down
3 changes: 1 addition & 2 deletions packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ export {
CORBIT_DEFAULT_COLOR,
CORBIT_VISOR_COLOR,
CORBIT_GLINT_COLOR,
AVATAR_COLORS,
avatarColorClass,
AVATAR_PALETTE,
avatarColorForPrincipal,
avatarClassForPrincipal,
resolveAvatarFill,
Expand Down
7 changes: 4 additions & 3 deletions packages/chat-ui/test/avatar-composition.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";

import {
CorbitAvatar,
AVATAR_COLORS,
AVATAR_PALETTE,
CORBIT_DEFAULT_COLOR,
avatarClassForPrincipal,
avatarColorForPrincipal,
Expand All @@ -26,8 +26,9 @@ describe("avatar composition and identity resolution", () => {
const aliceColor = avatarColorForPrincipal("usr_alice");
const bobColor = avatarColorForPrincipal("usr_bob");

expect(AVATAR_COLORS).toContain(aliceColor);
expect(AVATAR_COLORS).toContain(bobColor);
const avatarColors = AVATAR_PALETTE.map((entry) => entry.color);
expect(avatarColors).toContain(aliceColor);
expect(avatarColors).toContain(bobColor);

expect(avatarClassForPrincipal("usr_alice")).toContain(aliceColor);

Expand Down
Loading