Skip to content
220 linesCodeBlameRaw
1import { Bot, User } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { useRouteLoaderData } from "react-router";
4
5import type { AgentLook } from "@g1t/contracts";
6import { lookFromSeed } from "@g1t/contracts/agent-look";
7
8import { usercontentFrom } from "../../lib/addresses";
9import { AgentFace, type FaceState, faceColorCss } from "../agent-face";
10import { Mark } from "../logo";
11
12// shadcn/ui's avatar, as g1t draws one: an uploaded picture, or a letter
13// whose colour is stable for a given name; an agent's bot face, with the
14// agent marker in its corner. It is one element rather than Radix's
15// root/image/fallback so the server renders the letter at once and
16// nothing jumps; an image that fails to load falls back to the letter.
17
18const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
19
20/**
21 * g1t itself: its agent, as reviewer, assignee and commit author, and the
22 * system, as the author of security updates, the issues it opens and merges
23 * from the queue. One name, `g1t`.
24 */
25export function isSystemName(name: string | null | undefined): boolean {
26 return name === "g1t";
27}
28
29/**
30 * Where an uploaded avatar is served, from the hash it is stored by: the
31 * usercontent origin, or the site's own address (which redirects there)
32 * when it is not known.
33 */
34export function avatarUrl(avatar: string, usercontent = ""): string {
35 return `${usercontent}/avatars/${avatar}`;
36}
37
38/** What an agent's avatar is drawn from: its chosen face, else its seed's. */
39export type AvatarAgent = {
40 look?: AgentLook | null;
41 /** What draws the face when there is no look, and times its blink; the name when left out. */
42 seed?: string | null;
43 /** What the face is doing (components/agent-face.tsx). */
44 state?: FaceState;
45};
46
47/**
48 * An uploaded avatar if there is one, else a letter avatar whose colour is
49 * stable for a given name. People are round; a workspace is `square`. An
50 * image that fails to load falls back to the letter.
51 *
52 * With `agent`, it is an agent's: its uploaded picture, g1t's own mark, or
53 * its bot face (components/agent-face.tsx), on a squircle tile so it reads
54 * apart from people's round letters at any size, and from 16 px up the
55 * agent marker in its corner. It then names itself to a screen reader,
56 * "Margo, agent", where a person's avatar is decoration beside their name.
57 */
58export function Avatar({
59 name,
60 size = 20,
61 square,
62 image,
63 system,
64 agent,
65 ring,
66}: {
67 name: string;
68 size?: number;
69 square?: boolean;
70 /** The uploaded avatar's hash, as identity returns it. */
71 image?: string | null;
72 /** g1t itself (a user of kind `system`), whatever the name. */
73 system?: boolean;
74 /** An agent's avatar: `true` draws the face its name seeds, an object the look or seed it has. */
75 agent?: boolean | AvatarAgent;
76 /** The colour behind the avatar, for the agent marker's ring; the page's by default. */
77 ring?: string;
78}) {
79 const [failed, setFailed] = useState<string | null>(null);
80 const usercontent = usercontentFrom(useRouteLoaderData("root"));
81 const g1t = system || isSystemName(name);
82 if (agent) {
83 const own = typeof agent === "object" ? agent : {};
84 const seed = own.seed || name || "agent";
85 const radius = Math.round(size * 0.26);
86 const face = g1t ? (
87 <span
88 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
89 style={{ width: size, height: size, borderRadius: radius }}
90 >
91 <Mark className="size-full" />
92 </span>
93 ) : image && failed !== image ? (
94 <img
95 src={avatarUrl(image, usercontent)}
96 alt=""
97 width={size}
98 height={size}
99 loading="lazy"
100 decoding="async"
101 onError={() => setFailed(image)}
102 className="inline-block shrink-0 bg-raised object-cover"
103 style={{ width: size, height: size, borderRadius: radius }}
104 />
105 ) : (
106 <AgentTile size={size} radius={radius} color={faceColorCss((own.look ?? lookFromSeed(seed)).color)}>
107 <AgentFace look={own.look ?? null} seed={seed} size={size} state={own.state} />
108 </AgentTile>
109 );
110 return (
111 <span role="img" aria-label={`${name}, agent`} className="relative inline-flex shrink-0 align-middle">
112 {face}
113 {size >= 16 && <AgentMarker size={size} ring={ring} />}
114 </span>
115 );
116 }
117 // g1t itself wears its own mark: the pixel 1 on a dark square.
118 if (g1t) {
119 return (
120 <span
121 aria-hidden="true"
122 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
123 style={{ width: size, height: size, borderRadius: size * 0.24 }}
124 >
125 <Mark className="size-full" />
126 </span>
127 );
128 }
129 // ghost stands in for deleted accounts: a plain silhouette, as for
130 // anyone on a commit who has no account.
131 if (name === "ghost" && !image) {
132 return (
133 <span
134 aria-hidden="true"
135 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
136 style={{ width: size, height: size }}
137 >
138 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
139 </span>
140 );
141 }
142 if (image && failed !== image) {
143 return (
144 <img
145 src={avatarUrl(image, usercontent)}
146 alt=""
147 aria-hidden="true"
148 width={size}
149 height={size}
150 loading="lazy"
151 decoding="async"
152 onError={() => setFailed(image)}
153 className="inline-block shrink-0 bg-raised object-cover"
154 style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
155 />
156 );
157 }
158 let hash = 0;
159 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
160 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
161 return (
162 <span
163 aria-hidden="true"
164 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
165 style={{
166 width: size,
167 height: size,
168 borderRadius: square ? size * 0.24 : size,
169 fontSize: size * 0.5,
170 background: `oklch(0.4 0.09 ${hue})`,
171 color: `oklch(0.93 0.08 ${hue})`,
172 }}
173 >
174 {name[0]}
175 </span>
176 );
177}
178
179/**
180 * The tile an agent's face sits on: a squircle in a wash of the face's own
181 * colour, so a round-headed bot still reads as an agent beside a person's
182 * round letter.
183 */
184function AgentTile({ size, radius, color, children }: { size: number; radius: number; color: string; children: ReactNode }) {
185 return (
186 <span
187 className="inline-flex shrink-0 items-center justify-center overflow-visible"
188 style={{
189 width: size,
190 height: size,
191 borderRadius: radius,
192 background: `color-mix(in oklab, ${color} 20%, transparent)`,
193 boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 40%, transparent)`,
194 }}
195 >
196 {children}
197 </span>
198 );
199}
200
201/**
202 * The agent marker: a small bot on an accent disc, ringed in the colour
203 * behind it, in the corner of every agent's avatar from 16 px up (under
204 * that the squircle tile alone says it). The same mark everywhere, so
205 * nobody has to read a name to know they are looking at an agent.
206 */
207export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
208 const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
209 const icon = Math.max(6, Math.round(disc * 0.66));
210 const inset = Math.round(disc * 0.28);
211 return (
212 <span
213 aria-hidden="true"
214 className={`absolute flex items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`}
215 style={{ width: disc, height: disc, right: -inset, bottom: -inset, boxShadow: `0 0 0 ${size >= 28 ? 2 : 1.5}px ${ring}` }}
216 >
217 <Bot size={icon} strokeWidth={2.6} absoluteStrokeWidth={false} />
218 </span>
219 );
220}