Skip to content
140 linesCodeBlameRaw
1import { RotateCcw, Shuffle, Smile } from "lucide-react";
2
3import type { AgentLook } from "@g1t/contracts";
4import { LOOK_KEYS, LOOK_OPTIONS, PRESET_EXPRESSIONS, lookFromSeed, randomLook, sameLook } from "@g1t/contracts/agent-look";
5
6import { AgentFace, FACE_COLOR_LABELS, type FaceState, faceColorCss } from "../agent-face";
7import { Avatar } from "../ui/avatar";
8import { Button } from "../ui/button";
9import { Hint } from "../ui/hint";
10import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
11
12/** What each choice is called, in the hint over it. */
13const LABELS: { [K in keyof AgentLook]: Record<AgentLook[K], string> } = {
14 shape: { round: "Round", square: "Square", squircle: "Squircle", blob: "Blob", hex: "Hex" },
15 color: FACE_COLOR_LABELS,
16 eyes: { dots: "Dots", round: "Round", wide: "Wide", happy: "Happy", visor: "Visor", sleepy: "Sleepy" },
17 mouth: { smile: "Smile", grin: "Grin", flat: "Flat", open: "Open", none: "No mouth" },
18 antenna: { none: "No antenna", single: "One", double: "Two", bulb: "Bulb" },
19 accessory: { none: "Nothing", headphones: "Headphones", bow: "Bow", glasses: "Glasses", hat: "Hat", spark: "Spark" },
20 pattern: { none: "Plain", stripe: "Stripes", dots: "Dots", gradient: "Sheen" },
21};
22
23const PARTS: { [K in keyof AgentLook]: string } = {
24 shape: "Shape",
25 color: "Colour",
26 eyes: "Eyes",
27 mouth: "Mouth",
28 antenna: "Antenna",
29 accessory: "Accessory",
30 pattern: "Pattern",
31};
32
33const STATES: { state: FaceState; label: string }[] = [
34 { state: "idle", label: "Idle: it blinks and breathes" },
35 { state: "working", label: "Working: eyes narrowed, a soft pulse" },
36 { state: "asleep", label: "Asleep: paused or out of budget" },
37 { state: "happy", label: "Happy: a session finished well" },
38];
39
40/**
41 * An agent's face, chosen part by part (docs.g1t.sh/guides/agents/, "Its
42 * face"): a live preview, each state it can be in, then a row of choices
43 * for each of the seven parts, drawn as the face would be with that
44 * choice. Shuffle draws a random face; Reset gives it back the face its
45 * seed draws, which is what `null` means. A personality preset can lend
46 * its expression, but only when asked: a chosen look is never changed
47 * behind someone's back.
48 */
49export function FaceEditor({
50 value,
51 seed,
52 name = "agent",
53 preset,
54 onChange,
55}: {
56 /** The chosen look, or null for the seed's face. */
57 value: AgentLook | null;
58 seed: string;
59 /** The agent's name, for the preview's label. */
60 name?: string;
61 /** Its personality preset, whose expression it can borrow. */
62 preset?: string | null;
63 onChange: (look: AgentLook | null) => void;
64}) {
65 const own = lookFromSeed(seed);
66 const current = value ?? own;
67 const expression = preset ? PRESET_EXPRESSIONS[preset] : undefined;
68 const wearsExpression = expression ? Object.entries(expression).every(([key, choice]) => current[key as keyof AgentLook] === choice) : true;
69 return (
70 <div className="grid gap-5 sm:grid-cols-[8rem_minmax(0,1fr)]">
71 <div className="flex flex-col items-center gap-3 sm:items-start">
72 <Avatar name={name} size={96} agent={{ look: value, seed }} />
73 <div role="list" aria-label="How it looks in each state" className="flex items-center gap-2">
74 {STATES.map(({ state, label }) => (
75 <Hint key={state} label={label}>
76 <span role="listitem" className="inline-flex rounded-md">
77 <AgentFace look={value} seed={seed} size={28} state={state} />
78 </span>
79 </Hint>
80 ))}
81 </div>
82 <p className="text-xs text-faint">{value ? "A chosen face." : "Its own face, drawn from its seed."}</p>
83 </div>
84 <div className="min-w-0 space-y-3">
85 <div className="flex flex-wrap gap-2">
86 <Button type="button" variant="outline" size="sm" onClick={() => onChange(randomLook())}>
87 <Shuffle size={14} />
88 Shuffle
89 </Button>
90 <Button type="button" variant="outline" size="sm" disabled={!value || sameLook(value, own)} onClick={() => onChange(null)}>
91 <RotateCcw size={14} />
92 Reset to its own
93 </Button>
94 {expression && (
95 <Hint label={`A ${preset} voice suggests ${Object.values(expression).join(" and ")}. Only when you choose it.`}>
96 <Button type="button" variant="ghost" size="sm" disabled={wearsExpression} onClick={() => onChange({ ...current, ...expression })}>
97 <Smile size={14} />
98 Match its voice
99 </Button>
100 </Hint>
101 )}
102 </div>
103 {LOOK_KEYS.map((key) => (
104 <div key={key} className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
105 <span id={`face-${key}`} className="w-20 shrink-0 text-xs font-medium text-fg-soft">
106 {PARTS[key]}
107 </span>
108 <ToggleGroup
109 type="single"
110 variant="outline"
111 size="sm"
112 value={current[key]}
113 onValueChange={(choice) => {
114 if (choice) onChange({ ...current, [key]: choice } as AgentLook);
115 }}
116 aria-labelledby={`face-${key}`}
117 className="min-w-0"
118 >
119 {(LOOK_OPTIONS[key] as readonly AgentLook[typeof key][]).map((choice) => (
120 <Hint key={choice} label={(LABELS[key] as Record<string, string>)[choice]}>
121 <ToggleGroupItem
122 value={choice}
123 aria-label={(LABELS[key] as Record<string, string>)[choice]}
124 className="h-11 min-w-11 px-1.5 data-[state=on]:shadow-[inset_0_0_0_1.5px_var(--color-accent)]"
125 >
126 {key === "color" ? (
127 <span aria-hidden="true" className="size-6 rounded-full ring-1 ring-black/15 ring-inset" style={{ background: faceColorCss(choice as AgentLook["color"]) }} />
128 ) : (
129 <AgentFace look={{ ...current, [key]: choice } as AgentLook} seed={seed} size={36} className="[&_.face-eyes]:animate-none [&_.face-bob]:animate-none" />
130 )}
131 </ToggleGroupItem>
132 </Hint>
133 ))}
134 </ToggleGroup>
135 </div>
136 ))}
137 </div>
138 </div>
139 );
140}