aaavatars 2.1.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # aaavatars
2
2
 
3
- Deterministic 2D character avatars for AI agents. Same name, same character. A `status` prop drives looping states and `play()` triggers one-shot reactions.
3
+ Deterministic 2D character avatars for AI agents. Same name, same character. Its state is four [layers](#layers): `activity`, `expression` and `gaze` drive looping states, expressed through the eyes, and `play()` triggers one-shot reactions.
4
4
 
5
5
  [Live demo](https://rsmdt.github.io/aaavatars/)
6
6
 
@@ -11,13 +11,13 @@ npm install aaavatars
11
11
  ```
12
12
 
13
13
  ```tsx
14
- import { Avatar, AvatarGroup, type AvatarHandle, parseAvatarValue } from "aaavatars";
14
+ import { Avatar, AvatarGroup, type AvatarHandle } from "aaavatars";
15
15
 
16
16
  const avatar = useRef<AvatarHandle>(null);
17
17
 
18
- <Avatar ref={avatar} name="Chief of Staff" size={36} status="working" label="Searching the web" />;
18
+ <Avatar ref={avatar} name="Chief of Staff" size={36} activity="coding" label="Writing code" />;
19
19
 
20
- await avatar.current?.play("done");
20
+ await avatar.current?.play("success");
21
21
  ```
22
22
 
23
23
  ## Avatar
@@ -26,23 +26,122 @@ await avatar.current?.play("done");
26
26
  |---|---|
27
27
  | `name` | seed string |
28
28
  | `frame` | `false` (no background), `0` (square) to `0.5` (circle) |
29
- | `status` | `idle`, `thinking`, `working`, `waiting`, `error`, `sleeping` |
29
+ | `activity` | `idle`, `thinking`, `working`, `reading`, `searching`, `reviewing`, `coding`, `writing`, `waiting`, `asking`, `listening`, `speaking`, `stuck`, `sleeping` |
30
+ | `expression` | `neutral`, `happy`, `sad`, `angry`, `confused`, `curious`, `tired`, `love` |
31
+ | `gaze` | `follow` (pointer), `ponder`, `scan`, `read`, `wander`, `ahead`, `down`, `away`; defaults per activity |
30
32
  | `shape` | `circle`, `squircle`, `pill`, `hex`, `triangle`, `drop`, `cloud`, `blob` |
31
33
  | `eyes` | `round`, `dot`, `tall`, `wide` |
32
34
  | `mouth` | `none`, `smile`, `flat`, `open`, or `renderMouth={(slot) => <svg element />}` |
33
35
  | `accessory` | `none`, `antenna`, `sprout`, `hat`, `glasses`, `bowtie` |
34
36
  | `content` | `face` or `initial` |
35
37
  | `colors` / `body` / `background` | palette to pick from, or explicit hex colours |
36
- | `fill` / `shading` | `solid` or `gradient`; `0` to `1` |
38
+ | `fill` / `shading` | `solid` or `gradient`; soft-clay depth from `0` (flat, the default) to `1` |
37
39
  | `src` | image URL, falls back to the character when it fails to load |
38
40
  | `badge` | node pinned to the bottom-right corner |
39
41
  | `label` | tooltip and accessible description of the current action |
40
- | `blink` / `look` / `motion` | turn blinking, pointer following or looping off |
42
+ | `blink` / `motion` | turn blinking or looping off |
41
43
  | `interactive` | click, double-click, drag-to-squish and press-and-hold reactions |
44
+ | `onChange` | called with the event when one starts playing, from `play()` or an interactive reaction, and with `null` when it ends |
42
45
 
43
- Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` stay off until you set them. Each name blinks, drifts and loops at its own slightly different tempo and phase, in CSS only. With `look`, eyes follow a mouse or pen anywhere on the page through one shared listener, updated once per frame.
46
+ Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` stay off until you set them.
44
47
 
45
- `play(event)` accepts `acknowledge`, `handoff`, `done`, `success`, `failure`, `surprise`, `wake` and resolves when the animation ends. Under `prefers-reduced-motion`, or while the page is hidden, it resolves immediately.
48
+ With `shading` above `0`, the head is shaded like soft clay: a darker rim, a sheen that rolls with the gaze, and a soft contact shadow that stays put while the body bobs.
49
+
50
+ ## Layers
51
+
52
+ A character's state is four independent layers. Each answers one question, and each can change without touching the others.
53
+
54
+ | Layer | Answers | Lasts | Set with |
55
+ |---|---|---|---|
56
+ | Activity | what is the agent doing? | until changed | `activity` prop |
57
+ | Expression | how does it feel? | until changed | `expression` prop |
58
+ | Gaze | where is it looking? | until changed | `gaze` prop, defaults per activity |
59
+ | Event | what just happened? | about 1 to 3 seconds, then back to rest | `play(event)` |
60
+
61
+ The eyes carry every layer, so a face without a mouth still reads. Each eye has a top and bottom lid, and its outline can morph into a glyph.
62
+
63
+ When two layers want the same part of the face, the more specific one wins:
64
+
65
+ 1. An event owns the whole face while it plays. Glyph eyes morph back to round eyes for it, except `error`, which morphs them into `x x`, and return when it ends.
66
+ 2. A non-`neutral` expression owns the eyes, the blinking and, when a mouth is set, the mouth.
67
+ 3. The activity owns the body motion, the accessory motion and, while the expression is `neutral`, the eye pose and the mouth.
68
+ 4. The gaze moves where the eyes point. It never changes their shape.
69
+
70
+ `sleeping` is the one exception: it keeps the eyes closed whatever the expression.
71
+
72
+ ### Activity
73
+
74
+ An activity is the body language of a task. It is not a combination of an expression and a gaze. It sets the body's motion, picks a default gaze, and, only while the expression is `neutral`, may give the eyes their own pose: a glyph, a lid shape or a loop. It never sets an emotion, so `activity="coding" expression="happy"` is a happy coder, with the happy eyes replacing the `>_` until the expression returns to `neutral`.
75
+
76
+ | Activity | Body | Eyes, with a `neutral` expression | Default gaze |
77
+ |---|---|---|---|
78
+ | `idle` | slow drift | | `follow` |
79
+ | `thinking` | sway | | `ponder` |
80
+ | `working` | quick bob | | `scan` |
81
+ | `reading` | still | | `read` |
82
+ | `searching` | leans in | dart in short jumps and hold, looking for something | `ahead` |
83
+ | `reviewing` | head dips with the eyes | narrow to a slit, look down, up, back, then down once more; a 6s loop | `ahead` |
84
+ | `coding` | bob | morph into a sharp `>_` prompt with a blinking cursor | `ahead` |
85
+ | `writing` | slow bob | | `read` |
86
+ | `waiting` | still | settle to the centre, then circle around each other twice every 3.2s | `ahead` |
87
+ | `asking` | head tilted | still, one eye wider: the person's turn to answer or approve | `ahead` |
88
+ | `listening` | nod | slide together into one tall blinking cursor | `ahead` |
89
+ | `speaking` | bob, mouth talks | | `follow` |
90
+ | `stuck` | tilted | sweep side to side once, then morph into sharp `x x` crosses | `ahead` |
91
+ | `sleeping` | breathe, sunk | droop, catch themselves once, droop again and close. Leaving it wakes them: half-open, wide, back to rest | `ahead` |
92
+
93
+ ### Expression
94
+
95
+ An expression is a mood that can last for minutes, shown through the lids and, when set, the mouth. A feeling that only makes sense for a moment, like surprise, is an event instead, so no value belongs to both layers.
96
+
97
+ | Expression | Eyes |
98
+ |---|---|
99
+ | `neutral` | the activity's eyes |
100
+ | `happy` | bottom lids raised |
101
+ | `sad` | top lids down, tilted outward |
102
+ | `angry` | top lids down, tilted inward |
103
+ | `confused` | one lid lowered |
104
+ | `curious` | both eyes slightly wider |
105
+ | `tired` | heavy top lids, drooping outward |
106
+ | `love` | both eyes tilt together into one beating heart |
107
+
108
+ ### Gaze
109
+
110
+ A gaze is a pattern of where to look. It works with any activity and any eye shape, and only moves the eyes. A loop that also needs the lids or the head, like `reviewing` or `waiting`, belongs to the activity instead.
111
+
112
+ | Gaze | Eyes |
113
+ |---|---|
114
+ | `follow` | follow a mouse or pen anywhere on the page while it moves, then return to glancing 0.9s after it stops |
115
+ | `wander` | glance around and hold, ignoring the pointer |
116
+ | `ponder` | look up-left, then up-right, then back |
117
+ | `scan` | sweep across: slow right, quick jump back |
118
+ | `read` | read line by line: three short hops right, jump back and down a line, three lines, then back to the top |
119
+ | `ahead` | look straight out |
120
+ | `down` | look down at the work, drifting a little side to side |
121
+ | `away` | look off to the side, with a brief check-in straight ahead |
122
+
123
+ ### Event
124
+
125
+ An event is a one-off reaction. `play(event)` resolves when the animation ends. Each reaction first centres the eyes and settles for half a second, except when triggered by a click or press.
126
+
127
+ An event does not change the `activity`, `expression` or `gaze` props. `EVENT_LAYERS[event]` names the expression and gaze it shows while it plays, for example `success` shows `happy` eyes looking `ahead`. Pair it with `onChange` to mirror the face in your own UI.
128
+
129
+ | Event | Reaction |
130
+ |---|---|
131
+ | `acknowledge` | a double nod of the eyes |
132
+ | `handoff` | eyes and body lean to the side, then return |
133
+ | `success` | happy eyes with a long bounce |
134
+ | `warning` | a wince: the eyes shift aside, heavy lids press down twice, the head dips and rises |
135
+ | `error` | the eyes morph into `x x` crosses, then the head and eyes sink, hold, and recover |
136
+ | `surprised` | a squint, then wide eyes with a tremble |
137
+ | `sigh` | a small breath in, then the lids sink and the head slumps before it recovers |
138
+ | `yawn` | the head stretches tall while the eyes close, then a blink awake |
139
+
140
+ Under `prefers-reduced-motion`, or while the page is hidden, `play()` resolves immediately.
141
+
142
+ ### Rendering
143
+
144
+ Each name blinks, glances and loops at its own tempo and phase, in CSS only. The `follow` gaze uses one shared pointer listener, updated once per frame. Offscreen avatars pause their loops. The glyphs (`>_`, `x x`, the cursor) are the eyes themselves, morphing their outline: smoothly in Chrome and Firefox, as an instant swap in Safari, which does not animate the SVG `d` property.
46
145
 
47
146
  ## AvatarGroup
48
147
 
@@ -50,10 +149,6 @@ Unset `shape`, `eyes` and colours come from the name. `mouth` and `accessory` st
50
149
  <AvatarGroup max={4} size={28} members={[{ name: "Ada", content: "initial" }, { name: "Research" }]} />
51
150
  ```
52
151
 
53
- ## Stored value
54
-
55
- `formatAvatarValue({ shape: "hex", accessory: "hat" })` gives `shape=hex;accessory=hat`. `parseAvatarValue(text)` returns `{ ok: true, value }` to spread onto `<Avatar>`, or `{ ok: false, error }` naming the invalid field.
56
-
57
152
  ## Static SVG
58
153
 
59
154
  ```ts
package/dist/Avatar.d.ts CHANGED
@@ -2,14 +2,23 @@ import { type ReactNode } from "react";
2
2
  import { type AvatarEvent } from "./animations.js";
3
3
  import { type RenderMouth } from "./parts.js";
4
4
  import { type Accessory, type Eyes, type Fill, type Mouth, type Shape } from "./spec.js";
5
- export declare const AVATAR_STATUSES: readonly ["idle", "thinking", "working", "waiting", "error", "sleeping"];
6
- export type AvatarStatus = (typeof AVATAR_STATUSES)[number];
5
+ export declare const ACTIVITIES: readonly ["idle", "thinking", "working", "reading", "searching", "reviewing", "coding", "writing", "waiting", "asking", "listening", "speaking", "stuck", "sleeping"];
6
+ export declare const EXPRESSIONS: readonly ["neutral", "happy", "sad", "angry", "confused", "curious", "tired", "love"];
7
+ export declare const GAZES: readonly ["follow", "ponder", "scan", "read", "wander", "ahead", "down", "away"];
8
+ export type Activity = (typeof ACTIVITIES)[number];
9
+ export type Expression = (typeof EXPRESSIONS)[number];
10
+ export type Gaze = (typeof GAZES)[number];
11
+ export declare const EVENT_LAYERS: Record<AvatarEvent, {
12
+ expression: Expression;
13
+ gaze: Gaze;
14
+ }>;
7
15
  export type AvatarContent = "face" | "initial";
8
16
  export interface AvatarHandle {
9
17
  play: (event: AvatarEvent) => Promise<void>;
10
18
  }
11
19
  export interface AvatarProps {
12
20
  accessory?: Accessory;
21
+ activity?: Activity;
13
22
  background?: string;
14
23
  badge?: ReactNode;
15
24
  blink?: boolean;
@@ -17,21 +26,22 @@ export interface AvatarProps {
17
26
  className?: string;
18
27
  colors?: readonly string[];
19
28
  content?: AvatarContent;
29
+ expression?: Expression;
20
30
  eyes?: Eyes;
21
31
  fill?: Fill;
22
32
  frame?: false | number;
33
+ gaze?: Gaze;
23
34
  interactive?: boolean;
24
35
  label?: string;
25
- look?: boolean;
26
36
  motion?: boolean;
27
37
  mouth?: Mouth;
28
38
  name: string;
39
+ onChange?: (event: AvatarEvent | null) => void;
29
40
  renderMouth?: RenderMouth;
30
41
  shading?: number;
31
42
  shape?: Shape;
32
43
  size?: number;
33
44
  src?: string;
34
- status?: AvatarStatus;
35
45
  }
36
46
  export declare function frameRadius(frame: false | number | undefined): string | undefined;
37
47
  export declare const Avatar: import("react").ForwardRefExoticComponent<AvatarProps & import("react").RefAttributes<AvatarHandle>>;
package/dist/Avatar.js CHANGED
@@ -1,19 +1,70 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
- import { EVENT_ANIMATIONS, MOOD_SWAP_REST, MOOD_SWAP_SHOW, } from "./animations.js";
3
+ import { EVENT_ANIMATIONS, MOOD_SWAP_REST, MOOD_SWAP_SHOW, PREP_MS, SLEEP_ANIMATION, STUCK_ANIMATION, WAKE_ANIMATION, stage, } from "./animations.js";
4
4
  import { SHAPE_GEOMETRY } from "./geometry.js";
5
5
  import { AccessoryShape, EyePair, MoodMouths, MouthShape, mouthSlot, } from "./parts.js";
6
6
  import { createSpec, shadeHex, } from "./spec.js";
7
- import { watchPointer } from "./pointer.js";
7
+ import { restartGazeCentred, watchPointer } from "./pointer.js";
8
8
  import { AVATAR_CSS } from "./styles.js";
9
- export const AVATAR_STATUSES = [
9
+ import { watchVisibility } from "./visibility.js";
10
+ export const ACTIVITIES = [
10
11
  "idle",
11
12
  "thinking",
12
13
  "working",
14
+ "reading",
15
+ "searching",
16
+ "reviewing",
17
+ "coding",
18
+ "writing",
13
19
  "waiting",
14
- "error",
20
+ "asking",
21
+ "listening",
22
+ "speaking",
23
+ "stuck",
15
24
  "sleeping",
16
25
  ];
26
+ export const EXPRESSIONS = [
27
+ "neutral",
28
+ "happy",
29
+ "sad",
30
+ "angry",
31
+ "confused",
32
+ "curious",
33
+ "tired",
34
+ "love",
35
+ ];
36
+ export const GAZES = ["follow", "ponder", "scan", "read", "wander", "ahead", "down", "away"];
37
+ const ACTIVITY_GAZE = {
38
+ idle: "follow",
39
+ thinking: "ponder",
40
+ working: "scan",
41
+ reading: "read",
42
+ searching: "ahead",
43
+ reviewing: "ahead",
44
+ coding: "ahead",
45
+ writing: "read",
46
+ waiting: "ahead",
47
+ asking: "ahead",
48
+ listening: "ahead",
49
+ speaking: "follow",
50
+ stuck: "ahead",
51
+ sleeping: "ahead",
52
+ };
53
+ export const EVENT_LAYERS = {
54
+ acknowledge: { expression: "neutral", gaze: "ahead" },
55
+ handoff: { expression: "neutral", gaze: "ahead" },
56
+ success: { expression: "happy", gaze: "ahead" },
57
+ warning: { expression: "neutral", gaze: "ahead" },
58
+ error: { expression: "neutral", gaze: "ahead" },
59
+ surprised: { expression: "neutral", gaze: "ahead" },
60
+ sigh: { expression: "tired", gaze: "ahead" },
61
+ yawn: { expression: "tired", gaze: "ahead" },
62
+ };
63
+ const ACTIVITY_POSE = {
64
+ coding: "prompt",
65
+ stuck: "cross",
66
+ listening: "cursor",
67
+ };
17
68
  const DRAG_THRESHOLD = 3;
18
69
  const HOLD_MS = 600;
19
70
  const SQUISH_LIMIT = 40;
@@ -21,6 +72,44 @@ function prefersReducedMotion() {
21
72
  return (typeof window.matchMedia === "function" &&
22
73
  window.matchMedia("(prefers-reduced-motion: reduce)").matches);
23
74
  }
75
+ function lidPairs(root, lids) {
76
+ if (!root || !lids)
77
+ return [];
78
+ return [...root.querySelectorAll(".aaa-lid")].map((lid) => {
79
+ const half = Number(lid.dataset.h);
80
+ const top = lid.classList.contains("aaa-lid-top");
81
+ const side = lid.classList.contains("aaa-lid-l") ? -1 : 1;
82
+ return [
83
+ lid,
84
+ lids.map(({ bottom, easing, offset, tilt = 0, top: drop }) => {
85
+ if (bottom === undefined && drop === undefined)
86
+ return { offset, ...(easing && { easing }) };
87
+ const transform = top
88
+ ? `translateY(${((drop ?? 0) * half).toFixed(2)}px) rotate(${side * tilt}deg)`
89
+ : `translateY(${(-(bottom ?? 0) * half).toFixed(2)}px)`;
90
+ return { offset, transform, ...(easing && { easing }) };
91
+ }),
92
+ ];
93
+ });
94
+ }
95
+ function eyePairs(root, animation) {
96
+ const frames = animation.eye;
97
+ if (!root || !frames)
98
+ return [];
99
+ return [...root.querySelectorAll(".aaa-eye")].map((eye) => [
100
+ eye,
101
+ frames(eye.classList.contains("aaa-eye-l") ? -1 : 1, {
102
+ gap: Number(eye.dataset.gap),
103
+ h: Number(eye.dataset.h),
104
+ w: Number(eye.dataset.w),
105
+ }),
106
+ ]);
107
+ }
108
+ function heldValues(element, frames) {
109
+ const style = getComputedStyle(element);
110
+ const properties = new Set(frames.flatMap((frame) => Object.keys(frame).filter((key) => !["offset", "easing", "composite", "transform"].includes(key))));
111
+ return Object.fromEntries([...properties].map((property) => [property, style.getPropertyValue(property)]));
112
+ }
24
113
  function clampFrame(frame) {
25
114
  return Math.min(Math.max(frame, 0), 0.5);
26
115
  }
@@ -29,7 +118,7 @@ export function frameRadius(frame) {
29
118
  return undefined;
30
119
  return `${clampFrame(frame ?? 0.5) * 100}%`;
31
120
  }
32
- export const Avatar = forwardRef(function Avatar({ accessory, background, badge, blink = true, body, className, colors, content = "face", eyes, fill = "solid", frame = 0.5, interactive = false, label, look = true, motion = true, mouth, name, renderMouth, shading = 0, shape, size = 64, src, status = "idle", }, ref) {
121
+ export const Avatar = forwardRef(function Avatar({ accessory, activity = "idle", background, badge, blink = true, body, className, colors, content = "face", expression = "neutral", eyes, fill = "solid", frame = 0.5, gaze, interactive = false, label, motion = true, mouth, name, onChange, renderMouth, shading = 0, shape, size = 64, src, }, ref) {
33
122
  const uid = `aaa-${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
34
123
  const svgRef = useRef(null);
35
124
  const bodyRef = useRef(null);
@@ -39,6 +128,10 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
39
128
  const drag = useRef(null);
40
129
  const suppressClick = useRef(false);
41
130
  const [imageFailed, setImageFailed] = useState(false);
131
+ const [reacting, setReacting] = useState(null);
132
+ const latestOnChange = useRef(onChange);
133
+ latestOnChange.current = onChange;
134
+ const latestPlay = useRef(0);
42
135
  useEffect(() => setImageFailed(false), [src]);
43
136
  const spec = useMemo(() => createSpec(name, { accessory, background, body, colors, eyes, mouth, shape }), [name, accessory, background, body, colors, eyes, mouth, shape]);
44
137
  const geometry = SHAPE_GEOMETRY[spec.shape];
@@ -47,13 +140,18 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
47
140
  const showImage = Boolean(src) && !imageFailed;
48
141
  const hasMouth = content === "face" && (Boolean(renderMouth) || spec.mouth !== "none");
49
142
  const accessoryBehind = spec.accessory === "antenna" || spec.accessory === "sprout";
50
- const tracksPointer = look && !showImage;
143
+ const resolvedGaze = gaze ?? ACTIVITY_GAZE[activity];
144
+ const shownExpression = activity === "sleeping" ? "neutral" : expression;
145
+ const pose = reacting ?? (shownExpression === "neutral" ? (ACTIVITY_POSE[activity] ?? "eye") : "eye");
146
+ const tracksPointer = resolvedGaze === "follow" && !showImage;
51
147
  const slot = mouthSlot(geometry, spec.eyeColor);
52
148
  const bodyFill = fill === "gradient" ? `url(#${uid}-fill)` : spec.body;
149
+ const clay = shading > 0;
150
+ const sheenY = geometry.top + (geometry.bottom - geometry.top) * 0.26;
53
151
  const placement = framed
54
152
  ? `translate(${50 + spec.offsetX} ${50 + spec.offsetY}) rotate(${spec.rotate}) scale(0.78) translate(-50 -50)`
55
153
  : "translate(50 50) scale(0.92) translate(-50 -50)";
56
- const play = useCallback((event) => {
154
+ const run = useCallback((name, animation, immediate = false) => {
57
155
  for (const animation of running.current)
58
156
  animation.cancel();
59
157
  const target = bodyRef.current;
@@ -64,8 +162,8 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
64
162
  prefersReducedMotion()) {
65
163
  return Promise.resolve();
66
164
  }
67
- const animation = EVENT_ANIMATIONS[event];
68
- const timing = { duration: animation.duration, easing: "ease-in-out" };
165
+ const prep = immediate ? 0 : (animation.prep ?? PREP_MS);
166
+ const timing = { duration: prep + animation.duration, easing: "linear" };
69
167
  const root = svgRef.current;
70
168
  const rest = root?.querySelector(".aaa-mouth-rest");
71
169
  const accessory = root?.querySelector(".aaa-accessory");
@@ -80,11 +178,50 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
80
178
  [rest, animation.mood ? (animation.mood.rest ?? MOOD_SWAP_REST) : animation.mouth],
81
179
  [mood, animation.mood ? (animation.mood.frames ?? MOOD_SWAP_SHOW) : undefined],
82
180
  [accessory, accessoryFrames],
181
+ ...lidPairs(root, animation.lids),
182
+ ...eyePairs(root, animation),
83
183
  ];
84
- running.current = pairs.flatMap(([element, frames]) => element && frames ? [element.animate(frames, timing)] : []);
85
- return Promise.all(running.current.map((animation) => animation.finished)).then(() => undefined, () => undefined);
184
+ const started = pairs.flatMap(([element, frames]) => element && frames?.length
185
+ ? [element.animate(stage(frames, animation.duration, prep, heldValues(element, frames)), timing)]
186
+ : []);
187
+ running.current = started;
188
+ root?.setAttribute("data-reacting", name);
189
+ setReacting(animation.pose ?? "eye");
190
+ return Promise.all(started.map((animation) => animation.finished)).then(() => undefined, () => undefined).then(() => {
191
+ if (running.current !== started || !root)
192
+ return;
193
+ restartGazeCentred(root);
194
+ root.removeAttribute("data-reacting");
195
+ setReacting(null);
196
+ });
197
+ }, []);
198
+ const play = useCallback(async (event, immediate = false) => {
199
+ const id = ++latestPlay.current;
200
+ latestOnChange.current?.(event);
201
+ await run(event, EVENT_ANIMATIONS[event], immediate);
202
+ if (id === latestPlay.current)
203
+ latestOnChange.current?.(null);
204
+ }, [run]);
205
+ useImperativeHandle(ref, () => ({ play: (event) => play(event) }), [play]);
206
+ const previousActivity = useRef(activity);
207
+ useEffect(() => {
208
+ const previous = previousActivity.current;
209
+ previousActivity.current = activity;
210
+ if (previous === activity)
211
+ return;
212
+ if (activity === "sleeping")
213
+ void run("sleep", SLEEP_ANIMATION);
214
+ else if (previous === "sleeping")
215
+ void run("wake", WAKE_ANIMATION);
216
+ else if (activity === "stuck")
217
+ void run("stuck", STUCK_ANIMATION);
218
+ }, [activity, play, run]);
219
+ useEffect(() => {
220
+ const svg = svgRef.current;
221
+ if (!svg)
222
+ return;
223
+ return watchVisibility(svg);
86
224
  }, []);
87
- useImperativeHandle(ref, () => ({ play }), [play]);
88
225
  useEffect(() => {
89
226
  const svg = svgRef.current;
90
227
  if (!tracksPointer || !svg || prefersReducedMotion())
@@ -102,7 +239,7 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
102
239
  };
103
240
  state.holdTimer = setTimeout(() => {
104
241
  state.held = true;
105
- void play("success");
242
+ void play("success", true);
106
243
  }, HOLD_MS);
107
244
  drag.current = state;
108
245
  }
@@ -143,12 +280,16 @@ export const Avatar = forwardRef(function Avatar({ accessory, background, badge,
143
280
  suppressClick.current = false;
144
281
  return;
145
282
  }
146
- void play(event.detail > 1 ? "surprise" : "acknowledge");
283
+ void play(event.detail > 1 ? "surprised" : "acknowledge", true);
147
284
  }
148
- const svg = (_jsxs("svg", { ref: svgRef, "aria-label": label ? `${name}, ${label}` : name, className: className && !badge ? `aaa ${className}` : "aaa", "data-blink": blink ? undefined : "off", "data-interactive": interactive ? "" : undefined, "data-motion": motion ? undefined : "off", "data-status": status, height: size, overflow: framed ? undefined : "visible", role: "img", style: { "--aaa-phase": spec.phase, "--aaa-tempo": spec.tempo }, viewBox: "0 0 100 100", width: size, onClick: interactive ? handleClick : undefined, onPointerCancel: interactive ? endDrag : undefined, onPointerDown: interactive ? startDrag : undefined, onPointerMove: interactive ? moveDrag : undefined, onPointerUp: interactive ? endDrag : undefined, children: [label ? _jsx("title", { children: label }) : null, _jsx("style", { children: AVATAR_CSS }), _jsxs("defs", { children: [framed ? (_jsx("clipPath", { id: `${uid}-frame`, children: _jsx("rect", { width: 100, height: 100, rx: radius }) })) : null, fill === "gradient" ? (_jsxs("linearGradient", { id: `${uid}-fill`, x1: "0", y1: "0", x2: "1", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: shadeHex(spec.body, 0.18) }), _jsx("stop", { offset: "100%", stopColor: shadeHex(spec.body, -0.22) })] })) : null, shading > 0 ? (_jsxs("radialGradient", { id: `${uid}-shade`, cx: "0.35", cy: "0.3", r: "0.85", children: [_jsx("stop", { offset: "0%", stopColor: "#ffffff", stopOpacity: 0.55 * shading }), _jsx("stop", { offset: "45%", stopColor: "#ffffff", stopOpacity: 0 }), _jsx("stop", { offset: "100%", stopColor: "#000000", stopOpacity: 0.35 * shading })] })) : null] }), framed ? (_jsx("rect", { "data-frame": "", width: 100, height: 100, rx: radius, fill: spec.background })) : null, _jsx("g", { clipPath: framed ? `url(#${uid}-frame)` : undefined, children: showImage ? (_jsx("g", { ref: bodyRef, className: "aaa-react", children: _jsx("image", { "data-image": "", height: 100, href: src, preserveAspectRatio: "xMidYMid slice", width: 100, onError: () => setImageFailed(true) }) })) : (_jsx("g", { transform: placement, children: _jsx("g", { ref: squishRef, className: "aaa-squish", children: _jsx("g", { className: "aaa-body-motion", children: _jsxs("g", { ref: bodyRef, className: "aaa-react", children: [accessoryBehind ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null, _jsx("path", { d: geometry.path, fill: bodyFill, stroke: bodyFill, strokeLinejoin: "round", strokeWidth: 8 }), shading > 0 ? (_jsx("path", { d: geometry.path, fill: `url(#${uid}-shade)`, stroke: `url(#${uid}-shade)`, strokeLinejoin: "round", strokeWidth: 8 })) : null, _jsx("g", { ref: eyesRef, className: "aaa-eyes-react", fill: spec.eyeColor, children: _jsx("g", { className: "aaa-look", children: content === "initial" ? (_jsx("text", { dominantBaseline: "central", fontFamily: "system-ui, sans-serif", fontSize: 38, fontWeight: 700, textAnchor: "middle", x: 50, y: (geometry.top + geometry.bottom) / 2 + 2, children: name.trim().charAt(0).toUpperCase() })) : (_jsx("g", { className: "aaa-eyes-motion", children: _jsx("g", { className: "aaa-blink", style: {
149
- animationDelay: `${spec.blinkDelay}s`,
150
- animationDuration: `${spec.blinkDuration}s`,
151
- }, children: _jsx(EyePair, { eyes: spec.eyes, gap: geometry.eyeGap, y: geometry.eyeY }) }) })) }) }), hasMouth ? (_jsxs("g", { className: "aaa-look", children: [_jsx("g", { className: "aaa-mouth-rest", children: renderMouth ? (renderMouth(slot)) : (_jsx(MouthShape, { mouth: spec.mouth, slot: slot })) }), _jsx(MoodMouths, { slot: slot })] })) : null, !accessoryBehind && (spec.accessory !== "glasses" || content === "face") ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null] }) }) }) })) })] }));
285
+ const svg = (_jsxs("svg", { ref: svgRef, "aria-label": label ? `${name}, ${label}` : name, className: className && !badge ? `aaa ${className}` : "aaa", "data-activity": activity, "data-blink": blink ? undefined : "off", "data-expression": shownExpression, "data-gaze": resolvedGaze, "data-interactive": interactive ? "" : undefined, "data-motion": motion ? undefined : "off", height: size, overflow: framed ? undefined : "visible", role: "img", style: {
286
+ "--aaa-gap": geometry.eyeGap,
287
+ "--aaa-phase": spec.phase,
288
+ "--aaa-tempo": spec.tempo,
289
+ }, viewBox: "0 0 100 100", width: size, onClick: interactive ? handleClick : undefined, onPointerCancel: interactive ? endDrag : undefined, onPointerDown: interactive ? startDrag : undefined, onPointerMove: interactive ? moveDrag : undefined, onPointerUp: interactive ? endDrag : undefined, children: [label ? _jsx("title", { children: label }) : null, _jsx("style", { children: AVATAR_CSS }), _jsxs("defs", { children: [framed ? (_jsx("clipPath", { id: `${uid}-frame`, children: _jsx("rect", { width: 100, height: 100, rx: radius }) })) : null, fill === "gradient" ? (_jsxs("linearGradient", { id: `${uid}-fill`, x1: "0", y1: "0", x2: "1", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: shadeHex(spec.body, 0.18) }), _jsx("stop", { offset: "100%", stopColor: shadeHex(spec.body, -0.22) })] })) : null, clay ? (_jsxs(_Fragment, { children: [_jsx("clipPath", { id: `${uid}-body`, children: _jsx("path", { d: geometry.path }) }), _jsxs("mask", { id: `${uid}-outside`, maskUnits: "userSpaceOnUse", x: -20, y: -20, width: 140, height: 140, children: [_jsx("rect", { x: -20, y: -20, width: 140, height: 140, fill: "#fff" }), _jsx("path", { d: geometry.path, fill: "#000" })] }), _jsxs("radialGradient", { id: `${uid}-rim`, cx: "0.5", cy: "0.5", fx: "0.42", fy: "0.36", r: "0.5", children: [_jsx("stop", { offset: "45%", stopColor: "#000000", stopOpacity: 0 }), _jsx("stop", { offset: "80%", stopColor: "#000000", stopOpacity: 0.1 * shading }), _jsx("stop", { offset: "100%", stopColor: "#000000", stopOpacity: 0.3 * shading })] }), _jsxs("radialGradient", { id: `${uid}-sheen`, children: [_jsx("stop", { offset: "0%", stopColor: "#ffffff", stopOpacity: 0.6 * shading }), _jsx("stop", { offset: "100%", stopColor: "#ffffff", stopOpacity: 0 })] }), _jsxs("radialGradient", { id: `${uid}-shadow`, children: [_jsx("stop", { offset: "0%", stopColor: "#000000", stopOpacity: 0.3 * shading }), _jsx("stop", { offset: "100%", stopColor: "#000000", stopOpacity: 0 })] })] })) : null] }), framed ? (_jsx("rect", { "data-frame": "", width: 100, height: 100, rx: radius, fill: spec.background })) : null, _jsx("g", { clipPath: framed ? `url(#${uid}-frame)` : undefined, children: showImage ? (_jsx("g", { ref: bodyRef, className: "aaa-react", children: _jsx("image", { "data-image": "", height: 100, href: src, preserveAspectRatio: "xMidYMid slice", width: 100, onError: () => setImageFailed(true) }) })) : (_jsx("g", { transform: placement, children: _jsx("g", { ref: squishRef, className: "aaa-squish", children: _jsxs("g", { className: "aaa-head", children: [clay ? (_jsx("ellipse", { className: "aaa-shadow", cx: 50, cy: geometry.bottom + 5, fill: `url(#${uid}-shadow)`, rx: 30, ry: 5 })) : null, _jsx("g", { className: "aaa-body-motion", children: _jsxs("g", { ref: bodyRef, className: "aaa-react", children: [accessoryBehind ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null, _jsx("path", { d: geometry.path, fill: bodyFill, stroke: bodyFill, strokeLinejoin: "round", strokeWidth: 8 }), clay ? (_jsxs(_Fragment, { children: [_jsxs("g", { className: "aaa-rim", children: [_jsx("path", { d: geometry.path, fill: `url(#${uid}-rim)` }), _jsx("path", { d: geometry.path, fill: "none", mask: `url(#${uid}-outside)`, stroke: "#000000", strokeLinejoin: "round", strokeOpacity: 0.3 * shading, strokeWidth: 8 })] }), _jsx("g", { clipPath: `url(#${uid}-body)`, children: _jsx("g", { className: "aaa-look", children: _jsx("g", { className: "aaa-gaze", children: _jsx("ellipse", { className: "aaa-sheen", cx: 39, cy: sheenY, fill: `url(#${uid}-sheen)`, rx: 17, ry: 11, transform: `rotate(-24 39 ${sheenY})` }) }) }) })] })) : null, _jsx("g", { ref: eyesRef, className: "aaa-eyes-react", fill: spec.eyeColor, children: _jsx("g", { className: "aaa-look", children: content === "initial" ? (_jsx("text", { dominantBaseline: "central", fontFamily: "system-ui, sans-serif", fontSize: 38, fontWeight: 700, textAnchor: "middle", x: 50, y: (geometry.top + geometry.bottom) / 2 + 2, children: name.trim().charAt(0).toUpperCase() })) : (_jsx("g", { className: "aaa-gaze", children: _jsx("g", { className: "aaa-eyes-motion", children: _jsx("g", { className: "aaa-blink", style: {
290
+ animationDelay: `${spec.blinkDelay}s`,
291
+ animationDuration: `${spec.blinkDuration * 2}s`,
292
+ }, children: _jsx(EyePair, { eyes: spec.eyes, gap: geometry.eyeGap, id: uid, pose: pose, y: geometry.eyeY }) }) }) })) }) }), hasMouth ? (_jsxs("g", { className: "aaa-look", children: [_jsx("g", { className: "aaa-mouth-rest", children: renderMouth ? (renderMouth(slot)) : (_jsx(MouthShape, { mouth: spec.mouth, slot: slot })) }), _jsx(MoodMouths, { slot: slot })] })) : null, !accessoryBehind && (spec.accessory !== "glasses" || content === "face") ? (_jsx(AccessoryShape, { accent: spec.accent, accessory: spec.accessory, body: spec.body, geometry: geometry })) : null] }) })] }) }) })) })] }));
152
293
  if (!badge)
153
294
  return svg;
154
295
  return (_jsxs("span", { className: className, style: {
@@ -1,21 +1,46 @@
1
- export declare const AVATAR_EVENTS: readonly ["acknowledge", "handoff", "done", "success", "failure", "surprise", "wake"];
2
- import type { Mood } from "./parts.js";
1
+ export declare const AVATAR_EVENTS: readonly ["acknowledge", "handoff", "success", "warning", "error", "surprised", "sigh", "yawn"];
2
+ import type { EyePose, Mood } from "./parts.js";
3
3
  import type { Accessory } from "./spec.js";
4
4
  export type AvatarEvent = (typeof AVATAR_EVENTS)[number];
5
5
  type AccessoryFrames = Partial<Record<Exclude<Accessory, "none">, Keyframe[]>>;
6
- interface EventAnimation {
6
+ export interface LidFrame {
7
+ bottom?: number;
8
+ easing?: string;
9
+ offset: number;
10
+ tilt?: number;
11
+ top?: number;
12
+ }
13
+ export interface EyeSize {
14
+ gap: number;
15
+ h: number;
16
+ w: number;
17
+ }
18
+ export interface EventAnimation {
7
19
  accessory?: AccessoryFrames;
8
20
  body: Keyframe[];
9
21
  duration: number;
22
+ eye?: (side: -1 | 1, size: EyeSize) => Keyframe[];
10
23
  eyes: Keyframe[];
24
+ lids?: LidFrame[];
11
25
  mood?: {
12
26
  frames?: Keyframe[];
13
27
  name: Mood;
14
28
  rest?: Keyframe[];
15
29
  };
16
30
  mouth?: Keyframe[];
31
+ pose?: EyePose;
32
+ prep?: number;
17
33
  }
34
+ export declare const EASE_OUT = "cubic-bezier(.22,1,.36,1)";
35
+ export declare const EASE_IN_OUT = "cubic-bezier(.65,0,.35,1)";
36
+ export declare const EASE_HEAD = "cubic-bezier(.16,1,.3,1)";
37
+ export declare const EASE_SINE = "cubic-bezier(.37,0,.63,1)";
38
+ export declare const PREP_MS = 500;
39
+ export declare function stage(frames: Keyframe[], duration: number, prep: number, current?: Keyframe): Keyframe[];
18
40
  export declare const MOOD_SWAP_REST: Keyframe[];
19
41
  export declare const MOOD_SWAP_SHOW: Keyframe[];
20
42
  export declare const EVENT_ANIMATIONS: Record<AvatarEvent, EventAnimation>;
43
+ export declare const WAKE_ANIMATION: EventAnimation;
44
+ export declare const SLEEP_ANIMATION: EventAnimation;
45
+ export declare const STUCK_ANIMATION: EventAnimation;
21
46
  export {};