Skip to content

Commit b521a85

Browse files
feat(ui): give logged-in users their own chat bubble color (#112070)
Attributed senders' bubbles, footer names, and avatar initials now share a stable identity hue (same FNV-1a seed as avatar initials, hash % 360). Only the hue varies per user; saturation/lightness/alpha are fixed per theme mode so text contrast holds for every hue in light and dark themes. Unattributed local, assistant, and tool bubbles are unchanged. Closes #112068 Co-authored-by: Claude Fable 5 <[email protected]>
1 parent 023cdd6 commit b521a85

6 files changed

Lines changed: 112 additions & 4 deletions

File tree

ui/src/lib/identity-avatar.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment node
22
import { afterEach, describe, expect, it } from "vitest";
3-
import { resolveAvatar, setAvatarGatewayOrigin } from "./identity-avatar.ts";
3+
import { resolveAvatar, resolveIdentityHue, setAvatarGatewayOrigin } from "./identity-avatar.ts";
44

55
afterEach(() => {
66
setAvatarGatewayOrigin(null);
@@ -31,6 +31,18 @@ describe("resolveAvatar", () => {
3131
}
3232
});
3333

34+
it("derives a stable identity hue from the same seed as the initials color", () => {
35+
const first = resolveIdentityHue({ id: "profile_123", name: "Ada Lovelace" });
36+
const second = resolveIdentityHue({ id: "profile_123", name: "Renamed User" });
37+
expect(first).toBe(second);
38+
expect(first).toBeGreaterThanOrEqual(0);
39+
expect(first).toBeLessThan(360);
40+
const resolved = resolveAvatar({ id: "profile_123" });
41+
if (resolved.kind === "initials") {
42+
expect(first).toBe(resolved.colorSeed % 360);
43+
}
44+
});
45+
3446
it("lets an already-resolved profile avatar win", () => {
3547
expect(
3648
resolveAvatar({ id: "[email protected]", profileAvatarUrl: "/api/users/p1/avatar" }),

ui/src/lib/identity-avatar.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ export function resolveAvatarInitials(
9191
};
9292
}
9393

94+
/**
95+
* Stable identity hue (0-359) shared by avatar initials and per-sender chat
96+
* bubble tints; both must derive from the same seed or a user's bubble and
97+
* avatar drift apart. Lightness/alpha stay theme-owned in CSS.
98+
*/
99+
export function resolveIdentityHue(input: IdentityAvatarInput): number {
100+
return resolveAvatarInitials(input).colorSeed % 360;
101+
}
102+
94103
/**
95104
* Resolves a trusted gateway avatar route, else deterministic initials.
96105
* Gravatar is served by the gateway inside the profile avatar route itself, so

ui/src/pages/chat/chat-avatar.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import {
1717
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
1818
import type { SenderIdentity } from "../../lib/chat/sender-label.ts";
1919
import { formatSenderLabel } from "../../lib/chat/sender-label.ts";
20-
import { resolveAvatar, resolveAvatarInitials } from "../../lib/identity-avatar.ts";
20+
import {
21+
resolveAvatar,
22+
resolveAvatarInitials,
23+
resolveIdentityHue,
24+
} from "../../lib/identity-avatar.ts";
2125
import {
2226
DEFAULT_AGENT_ID,
2327
isUiGlobalSessionKey,
@@ -41,7 +45,7 @@ export function renderChatAvatar(
4145
const initials = resolveAvatarInitials(sender);
4246
const initialsAvatar = html`<div
4347
class="chat-avatar user chat-avatar--sender-initials"
44-
style=${`background: hsl(${initials.colorSeed % 360} 48% 42%)`}
48+
style=${`background: hsl(${resolveIdentityHue(sender)} 48% 42%)`}
4549
aria-label="${label}"
4650
>
4751
${initials.initials}

ui/src/pages/chat/components/chat-message.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,45 @@ describe("grouped chat rendering", () => {
15351535
});
15361536
});
15371537

1538+
it("tints attributed user groups with the sender's stable identity hue", () => {
1539+
const renderGroupFor = (sender?: { id: string; name: string }) => {
1540+
const container = document.createElement("div");
1541+
render(
1542+
renderMessageGroup(
1543+
{
1544+
kind: "group",
1545+
key: "tint-group",
1546+
role: "user",
1547+
...(sender ? { sender, senderLabel: sender.name } : {}),
1548+
messages: [
1549+
{ key: "tint-message", message: { role: "user", content: "hi", timestamp: 1000 } },
1550+
],
1551+
timestamp: 1000,
1552+
isStreaming: false,
1553+
},
1554+
{ showReasoning: true, showToolCalls: true },
1555+
),
1556+
container,
1557+
);
1558+
return container.querySelector<HTMLElement>(".chat-group.user");
1559+
};
1560+
1561+
const attributed = renderGroupFor({ id: "profile-1", name: "Alice Example" });
1562+
expect(attributed?.classList.contains("chat-group--sender-tint")).toBe(true);
1563+
const hue = Number(attributed?.style.getPropertyValue("--chat-sender-hue"));
1564+
expect(Number.isInteger(hue)).toBe(true);
1565+
expect(hue).toBeGreaterThanOrEqual(0);
1566+
expect(hue).toBeLessThan(360);
1567+
1568+
// Same sender always lands on the same hue; the local unattributed viewer
1569+
// keeps the accent skin.
1570+
const again = renderGroupFor({ id: "profile-1", name: "Alice Example" });
1571+
expect(again?.style.getPropertyValue("--chat-sender-hue")).toBe(String(hue));
1572+
const local = renderGroupFor();
1573+
expect(local?.classList.contains("chat-group--sender-tint")).toBe(false);
1574+
expect(local?.style.getPropertyValue("--chat-sender-hue")).toBe("");
1575+
});
1576+
15381577
it("uses the current profile display name for the signed-in user's historical messages", () => {
15391578
const container = document.createElement("div");
15401579
render(

ui/src/pages/chat/components/chat-message.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
formatTimeAgo,
4949
} from "../../../lib/format.ts";
5050
import "../../../components/tooltip.ts";
51+
import { resolveIdentityHue } from "../../../lib/identity-avatar.ts";
5152
import { getMediaFileExtension } from "../../../lib/media-file-extension.ts";
5253
import {
5354
openExternalUrlSafe,
@@ -1006,8 +1007,19 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
10061007
const lastMessageIndex = group.messages.length - 1;
10071008
const footerActionDetails = messageActionDetails[lastMessageIndex] ?? null;
10081009

1010+
// Attributed (logged-in) senders tint their bubbles with the same stable
1011+
// identity hue as their avatar initials; CSS owns per-theme lightness so
1012+
// the tint stays readable in both light and dark modes. Unattributed local
1013+
// messages keep the accent skin.
1014+
const senderHue =
1015+
normalizedRole === "user" && group.sender ? resolveIdentityHue(group.sender) : null;
1016+
10091017
return html`
1010-
<div class="chat-group ${roleClass}" data-chat-row-key=${group.key}>
1018+
<div
1019+
class="chat-group ${roleClass}${senderHue === null ? "" : " chat-group--sender-tint"}"
1020+
style=${senderHue === null ? nothing : `--chat-sender-hue: ${senderHue}`}
1021+
data-chat-row-key=${group.key}
1022+
>
10111023
${renderChatAvatar(
10121024
group.role,
10131025
{

ui/src/styles/chat/grouped.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,38 @@ img.chat-avatar {
633633
box-shadow: none;
634634
}
635635

636+
/* Per-user bubble tint: --chat-sender-hue is the sender's stable identity hue
637+
(same seed as their avatar initials). Only hue varies per user; saturation/
638+
lightness/alpha are fixed per theme mode so bubble text keeps contrast for
639+
every hue. Order contract: stays below the light-mode reset above — the
640+
dark rule outranks it by specificity, so light mode needs its own skin. */
641+
.chat-group.user.chat-group--sender-tint .chat-bubble {
642+
background: hsl(var(--chat-sender-hue) 48% 42% / 0.16);
643+
}
644+
645+
.chat-group.user.chat-group--sender-tint .chat-bubble:hover {
646+
border-color: hsl(var(--chat-sender-hue) 48% 55% / 0.4);
647+
}
648+
649+
.chat-group.user.chat-group--sender-tint .chat-sender-name {
650+
color: hsl(var(--chat-sender-hue) 45% 70%);
651+
}
652+
653+
:root[data-theme-mode="light"] .chat-group.user.chat-group--sender-tint .chat-bubble {
654+
background: hsl(var(--chat-sender-hue) 65% 94%);
655+
border-color: hsl(var(--chat-sender-hue) 45% 82%);
656+
}
657+
658+
/* Outranks the light skin above (it also sets border-color); without this the
659+
tinted hover border never applies in light mode. */
660+
:root[data-theme-mode="light"] .chat-group.user.chat-group--sender-tint .chat-bubble:hover {
661+
border-color: hsl(var(--chat-sender-hue) 48% 62%);
662+
}
663+
664+
:root[data-theme-mode="light"] .chat-group.user.chat-group--sender-tint .chat-sender-name {
665+
color: hsl(var(--chat-sender-hue) 60% 36%);
666+
}
667+
636668
/* ── Message metadata (tokens, cost, model, context %) ── */
637669
.msg-meta {
638670
display: inline-flex;

0 commit comments

Comments
 (0)