| 1 | import { type CSSProperties, useId } from "react"; |
| 2 | |
| 3 | import type { AgentLook, AgentSessionStatus, AgentStatus, FaceColor, FaceShape } from "@g1t/contracts"; |
| 4 | import { lookFromSeed } from "@g1t/contracts/agent-look"; |
| 5 | |
| 6 | import { 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 | */ |
| 25 | export 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. */ |
| 28 | export 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. */ |
| 35 | export 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 | */ |
| 46 | const 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. */ |
| 60 | export 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. */ |
| 74 | export 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. */ |
| 80 | const TOP: Record<FaceShape, number> = { round: 14, square: 16, squircle: 14, blob: 14, hex: 12 }; |
| 81 | |
| 82 | type HeadProps = { className?: string; fill?: string; stroke?: string; strokeWidth?: number }; |
| 83 | |
| 84 | /** The head, in a 100×100 box, leaving room above for an antenna. */ |
| 85 | function 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. */ |
| 101 | function 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 | |
| 110 | export 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 | } |