Skip to content
343 linesCodeBlameRaw
1import { type CSSProperties, useId } from "react";
2
3import type { AgentLook, AgentSessionStatus, AgentStatus, FaceColor, FaceShape } from "@g1t/contracts";
4import { lookFromSeed } from "@g1t/contracts/agent-look";
5
6import { cn } from "../lib/cn";
7
8/**
9 * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): a small bot
10 * drawn as SVG from its `look`, seven choices its owner makes, or from its
11 * `avatar_seed` when it has chosen none. The same look draws the same face
12 * at every size, from 16 px in a list to 128 px on a profile; at 20 px and
13 * under the fine details (accessory, pattern, highlights) are left off so
14 * what is left reads.
15 *
16 * It is alive but calm: it blinks every few seconds, at a moment and a
17 * pace of its own so a row of faces never blinks in unison, and breathes
18 * a little. Its `state` says what the agent is doing: `working` narrows
19 * its eyes and pulses softly, `asleep` closes them (with a small z above
20 * 40 px), `happy` is a brief grin and a bounce when work finished well.
21 * Everything moves with CSS transforms and opacity alone (app.css, "An
22 * agent's face"), and prefers-reduced-motion stops all of it: the face is
23 * then still, with the eyes a state would have.
24 */
25export type FaceState = "idle" | "working" | "asleep" | "happy";
26
27/** The face an agent's status calls for, where a list shows its status on its face. */
28export function faceStateOf(status: AgentStatus | null | undefined): FaceState {
29 if (status === "working") return "working";
30 if (status === "paused" || status === "out_of_budget") return "asleep";
31 return "idle";
32}
33
34/** The face beside a session: at work while it runs, happy once it finished well. */
35export function sessionFaceOf(status: AgentSessionStatus | null | undefined): FaceState {
36 if (status === "working" || status === "queued") return "working";
37 if (status === "done") return "happy";
38 return "idle";
39}
40
41/**
42 * Each colour as it reads on the dark theme and the light one: a pastel
43 * body on near-black, a step deeper on white, so the ink of the eyes and
44 * mouth holds at least 7:1 on either.
45 */
46const COLORS: Record<FaceColor, { dark: string; light: string }> = {
47 lavender: { dark: "#b6a8ff", light: "#a494f2" },
48 mint: { dark: "#86efc4", light: "#5fd3a6" },
49 peach: { dark: "#ffbd8c", light: "#f5a872" },
50 sky: { dark: "#8ab4ff", light: "#709ff0" },
51 pink: { dark: "#ff9ecf", light: "#f088bf" },
52 lemon: { dark: "#f2dc72", light: "#e5c94f" },
53 teal: { dark: "#7fe3e0", light: "#58cbc8" },
54 coral: { dark: "#ff8f85", light: "#f27a70" },
55 moss: { dark: "#b9d97a", light: "#9dc25a" },
56 slate: { dark: "#a9b4c6", light: "#8d9ab0" },
57};
58
59/** The colours' names as the Face editor says them. */
60export const FACE_COLOR_LABELS: Record<FaceColor, string> = {
61 lavender: "Lavender",
62 mint: "Mint",
63 peach: "Peach",
64 sky: "Sky",
65 pink: "Pink",
66 lemon: "Lemon",
67 teal: "Teal",
68 coral: "Coral",
69 moss: "Moss",
70 slate: "Slate",
71};
72
73/** A colour's swatch, for the editor: the dark theme's body on dark, the light one's on light. */
74export function faceColorCss(color: FaceColor): string {
75 const c = COLORS[color];
76 return `light-dark(${c.light}, ${c.dark})`;
77}
78
79/** Where each head shape's top is, for the antenna and the hat to sit on. */
80const TOP: Record<FaceShape, number> = { round: 14, square: 16, squircle: 14, blob: 14, hex: 12 };
81
82type HeadProps = { className?: string; fill?: string; stroke?: string; strokeWidth?: number };
83
84/** The head, in a 100×100 box, leaving room above for an antenna. */
85function Head({ shape, ...p }: { shape: FaceShape } & HeadProps) {
86 switch (shape) {
87 case "round":
88 return <circle cx={50} cy={54} r={40} {...p} />;
89 case "square":
90 return <rect x={12} y={16} width={76} height={76} rx={14} {...p} />;
91 case "squircle":
92 return <rect x={10} y={14} width={80} height={80} rx={30} {...p} />;
93 case "blob":
94 return <path d="M50 14 C 72 12, 92 28, 90 52 C 88 78, 70 94, 48 92 C 24 90, 8 74, 10 50 C 12 28, 30 16, 50 14 Z" {...p} />;
95 case "hex":
96 return <polygon points="50,12 86,32 86,76 50,96 14,76 14,32" strokeLinejoin="round" {...p} />;
97 }
98}
99
100/** FNV-1a, 32 bits: the seed as a number, for a blink of its own. */
101function hash(text: string): number {
102 let h = 0x811c9dc5;
103 for (let i = 0; i < text.length; i++) {
104 h ^= text.charCodeAt(i);
105 h = Math.imul(h, 0x01000193);
106 }
107 return h >>> 0;
108}
109
110export function AgentFace({
111 look,
112 seed = "agent",
113 size = 20,
114 state = "idle",
115 className,
116}: {
117 /** The face to draw; left out, the seed's. */
118 look?: AgentLook | null;
119 /** What the face is drawn from when there is no look, and what times its blink. */
120 seed?: string;
121 size?: number;
122 state?: FaceState;
123 className?: string;
124}) {
125 const face = look ?? lookFromSeed(seed);
126 const id = useId();
127 const tiny = size <= 20;
128 const small = size <= 32;
129 // Strokes in the 100-box: thicker as the face gets smaller, so a mouth is a mouth at 16 px.
130 const sw = tiny ? 9 : small ? 7 : 5.5;
131 const colour = COLORS[face.color];
132 const h = hash(seed || "agent");
133 // Blinks 3.6 to 6.8 s apart, starting anywhere in the cycle; breathing on its own beat too.
134 const blinkPeriod = (3600 + (h % 3200)) / 1000;
135 const blinkDelay = -(((h >>> 8) % 6000) / 1000);
136 const bobDelay = -(((h >>> 16) % 4000) / 1000);
137 const happy = state === "happy";
138 const asleep = state === "asleep";
139 const eyes = happy ? "happy" : face.eyes;
140 const mouth = happy ? "grin" : face.mouth;
141 const top = TOP[face.shape];
142 const clip = `${id}-clip`;
143 const sheen = `${id}-sheen`;
144 const style = {
145 "--face-body": `light-dark(${colour.light}, ${colour.dark})`,
146 "--face-blink-period": `${blinkPeriod}s`,
147 "--face-blink-delay": `${blinkDelay}s`,
148 "--face-bob-delay": `${bobDelay}s`,
149 } as CSSProperties;
150 return (
151 <svg
152 viewBox="0 0 100 100"
153 width={size}
154 height={size}
155 aria-hidden="true"
156 data-state={state}
157 data-size={tiny ? "tiny" : small ? "small" : "full"}
158 className={cn("agent-face shrink-0 overflow-visible", className)}
159 style={style}
160 >
161 <defs>
162 <clipPath id={clip}>
163 <Head shape={face.shape} />
164 </clipPath>
165 {face.pattern === "gradient" && !tiny && (
166 <linearGradient id={sheen} x1="0" y1="0" x2="0" y2="1">
167 <stop offset="0" stopColor="#ffffff" stopOpacity="0.45" />
168 <stop offset="0.55" stopColor="#ffffff" stopOpacity="0" />
169 </linearGradient>
170 )}
171 </defs>
172 <g className="face-bob">
173 {/* The antenna, behind the head. */}
174 {face.antenna === "single" && (
175 <g className="face-antenna" stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round">
176 <line x1={50} y1={top} x2={50} y2={7} />
177 <circle cx={50} cy={6} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
178 </g>
179 )}
180 {face.antenna === "double" && (
181 <g className="face-antenna" stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round">
182 <line x1={38} y1={top + 1} x2={31} y2={6} />
183 <line x1={62} y1={top + 1} x2={69} y2={6} />
184 <circle cx={30} cy={5} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
185 <circle cx={70} cy={5} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
186 </g>
187 )}
188 {face.antenna === "bulb" && (
189 <g className="face-antenna">
190 <line x1={50} y1={top} x2={50} y2={10} stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round" />
191 <circle className="face-bulb" cx={50} cy={7.5} r={tiny ? 7 : 6.5} fill="var(--face-glow)" stroke="var(--face-ink-out)" strokeWidth={tiny ? 0 : 2.5} />
192 </g>
193 )}
194 {/* The head: its colour, its pattern, its rim; and the glow a working face pulses. */}
195 <Head shape={face.shape} className="face-glow" fill="none" stroke="var(--face-glow)" strokeWidth={10} />
196 <Head shape={face.shape} fill="var(--face-body)" />
197 {!tiny && face.pattern === "stripe" && (
198 <g clipPath={`url(#${clip})`} fill="var(--face-ink)" opacity={0.12}>
199 <polygon points="0,100 100,0 100,16 16,100" />
200 <polygon points="0,78 78,0 90,0 0,90" />
201 </g>
202 )}
203 {!tiny && face.pattern === "dots" && (
204 <g clipPath={`url(#${clip})`} fill="var(--face-ink)" opacity={0.14}>
205 {[
206 [20, 28],
207 [36, 20],
208 [54, 18],
209 [72, 24],
210 [84, 40],
211 [18, 70],
212 [26, 86],
213 [50, 90],
214 [74, 86],
215 [86, 70],
216 ].map(([x, y]) => (
217 <circle key={`${x}:${y}`} cx={x} cy={y} r={4} />
218 ))}
219 </g>
220 )}
221 {!tiny && face.pattern === "gradient" && <Head shape={face.shape} fill={`url(#${sheen})`} />}
222 <Head shape={face.shape} fill="none" stroke="var(--face-rim)" strokeWidth={tiny ? 4 : 3} />
223
224 {/* Eyes, which blink, narrow when working and close asleep. */}
225 <g className="face-eyes">
226 {eyes === "dots" && (
227 <g fill="var(--face-ink)">
228 <circle cx={36} cy={50} r={tiny ? 7 : 5.5} />
229 <circle cx={64} cy={50} r={tiny ? 7 : 5.5} />
230 </g>
231 )}
232 {eyes === "round" && (
233 <g>
234 <circle cx={36} cy={50} r={9} fill="var(--face-ink)" />
235 <circle cx={64} cy={50} r={9} fill="var(--face-ink)" />
236 {!tiny && (
237 <g className="face-shine" fill="var(--face-light)">
238 <circle cx={39} cy={47} r={3} />
239 <circle cx={67} cy={47} r={3} />
240 </g>
241 )}
242 </g>
243 )}
244 {eyes === "wide" && (
245 <g>
246 <circle cx={36} cy={50} r={12} fill="var(--face-light)" />
247 <circle cx={64} cy={50} r={12} fill="var(--face-light)" />
248 <g className="face-pupils" fill="var(--face-ink)">
249 <circle cx={37} cy={51} r={6.5} />
250 <circle cx={65} cy={51} r={6.5} />
251 </g>
252 {!tiny && (
253 <g className="face-shine" fill="var(--face-light)">
254 <circle cx={39.5} cy={48.5} r={2.2} />
255 <circle cx={67.5} cy={48.5} r={2.2} />
256 </g>
257 )}
258 </g>
259 )}
260 {eyes === "happy" && (
261 <g fill="none" stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round">
262 <path d="M27 54 Q36 42 45 54" />
263 <path d="M55 54 Q64 42 73 54" />
264 </g>
265 )}
266 {eyes === "visor" && (
267 <g>
268 <rect x={21} y={41} width={58} height={18} rx={9} fill="var(--face-ink)" />
269 <g className="face-pupils" fill="var(--face-glow)">
270 <circle cx={36} cy={50} r={tiny ? 4.5 : 3.6} />
271 <circle cx={64} cy={50} r={tiny ? 4.5 : 3.6} />
272 </g>
273 </g>
274 )}
275 {eyes === "sleepy" && (
276 <g fill="var(--face-ink)">
277 <path d="M27 50 A9 9 0 0 0 45 50 Z" />
278 <path d="M55 50 A9 9 0 0 0 73 50 Z" />
279 </g>
280 )}
281 </g>
282
283 {/* The mouth. */}
284 {mouth === "smile" && <path d="M40 68 Q50 78 60 68" fill="none" stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round" />}
285 {mouth === "grin" && (
286 <g>
287 <path d="M37 66 Q50 84 63 66 Z" fill="var(--face-ink)" />
288 {!tiny && <path d="M40 67 Q50 71 60 67 L60 70 Q50 74 40 70 Z" fill="var(--face-light)" />}
289 </g>
290 )}
291 {mouth === "flat" && <line x1={42} y1={70} x2={58} y2={70} stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round" />}
292 {mouth === "open" && <ellipse cx={50} cy={70} rx={tiny ? 7 : 6} ry={tiny ? 8 : 7} fill="var(--face-ink)" />}
293
294 {/* Accessories, over everything; left off at 20 px and under. */}
295 {!tiny && face.accessory === "glasses" && (
296 <g fill="none" stroke="var(--face-ink)" strokeWidth={3.5} strokeLinecap="round">
297 <circle cx={36} cy={50} r={14} />
298 <circle cx={64} cy={50} r={14} />
299 <line x1={50} y1={49} x2={50} y2={49} strokeWidth={5} />
300 <line x1={20} y1={46} x2={14} y2={43} />
301 <line x1={80} y1={46} x2={86} y2={43} />
302 </g>
303 )}
304 {!tiny && face.accessory === "headphones" && (
305 <g>
306 <path d={`M 14 56 A 36 36 0 0 1 86 56`} fill="none" stroke="var(--face-ink-out)" strokeWidth={5} strokeLinecap="round" />
307 <rect x={6} y={48} width={14} height={22} rx={5} fill="var(--face-ink-out)" />
308 <rect x={80} y={48} width={14} height={22} rx={5} fill="var(--face-ink-out)" />
309 </g>
310 )}
311 {!tiny && face.accessory === "bow" && (
312 <g fill="var(--face-ribbon)" stroke="var(--face-ink-out)" strokeWidth={2} strokeLinejoin="round">
313 <path d={`M74 ${top + 6} L60 ${top - 2} L62 ${top + 14} Z`} />
314 <path d={`M74 ${top + 6} L88 ${top - 2} L86 ${top + 14} Z`} />
315 <circle cx={74} cy={top + 6} r={4} />
316 </g>
317 )}
318 {!tiny && face.accessory === "hat" && (
319 <g fill="var(--face-ink-out)">
320 <rect x={30} y={top - 13} width={40} height={16} rx={5} />
321 <rect x={23} y={top - 2} width={54} height={5.5} rx={2.75} />
322 <rect x={30} y={top - 6} width={40} height={3} fill="var(--face-ribbon)" />
323 </g>
324 )}
325 {!tiny && face.accessory === "spark" && (
326 <path className="face-spark" d={`M84 ${top - 8} L87 ${top - 1} L94 ${top + 2} L87 ${top + 5} L84 ${top + 12} L81 ${top + 5} L74 ${top + 2} L81 ${top - 1} Z`} fill="var(--face-spark)" />
327 )}
328
329 {/* Asleep: a z or two, above 40 px. */}
330 {asleep && size >= 40 && (
331 <g className="face-zz" fill="var(--face-ink-out)" fontFamily="var(--font-sans)" fontWeight={700}>
332 <text x={76} y={28} fontSize={18} className="face-z">
333 z
334 </text>
335 <text x={86} y={16} fontSize={12} className="face-z face-z-2">
336 z
337 </text>
338 </g>
339 )}
340 </g>
341 </svg>
342 );
343}