aaavatars 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rudolf Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # aaavatars
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Deterministic 2D character avatars for AI agents. Same name, same character. A `status` prop drives looping states and `play()` triggers one-shot reactions.
4
+
5
+ [Live demo](https://rsmdt.github.io/aaavatars/)
6
+
7
+ ![aaavatars demo](https://raw.githubusercontent.com/rsmdt/aaavatars/main/docs/demo.gif)
8
+
9
+ ```sh
10
+ npm install aaavatars
11
+ ```
12
+
13
+ ```tsx
14
+ import { Avatar, AvatarGroup, type AvatarHandle, parseAvatarValue } from "aaavatars";
15
+
16
+ const avatar = useRef<AvatarHandle>(null);
17
+
18
+ <Avatar ref={avatar} name="Chief of Staff" size={36} status="working" label="Searching the web" />;
19
+
20
+ await avatar.current?.play("done");
21
+ ```
22
+
23
+ ## Avatar
24
+
25
+ | Prop | Values |
26
+ |---|---|
27
+ | `name` | seed string |
28
+ | `frame` | `false` (no background), `0` (square) to `0.5` (circle) |
29
+ | `status` | `idle`, `thinking`, `working`, `waiting`, `error`, `sleeping` |
30
+ | `shape` | `circle`, `squircle`, `pill`, `hex`, `triangle`, `drop`, `cloud`, `blob` |
31
+ | `eyes` | `round`, `dot`, `tall`, `wide` |
32
+ | `mouth` | `none`, `smile`, `flat`, `open`, or `renderMouth={(slot) => <svg element />}` |
33
+ | `accessory` | `none`, `antenna`, `sprout`, `hat`, `glasses`, `bowtie` |
34
+ | `content` | `face` or `initial` |
35
+ | `colors` / `body` / `background` | palette to pick from, or explicit hex colours |
36
+ | `fill` / `shading` | `solid` or `gradient`; `0` to `1` |
37
+ | `src` | image URL, falls back to the character when it fails to load |
38
+ | `badge` | node pinned to the bottom-right corner |
39
+ | `label` | tooltip and accessible description of the current action |
40
+ | `blink` / `look` / `motion` | turn blinking, pointer following or looping off |
41
+ | `interactive` | click, double-click, drag-to-squish and press-and-hold reactions |
42
+
43
+ Unset `shape`, `eyes`, `mouth`, `accessory` and colours come from the name.
44
+
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.
46
+
47
+ ## AvatarGroup
48
+
49
+ ```tsx
50
+ <AvatarGroup max={4} size={28} members={[{ name: "Ada", content: "initial" }, { name: "Research" }]} />
51
+ ```
52
+
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
+ ## Static SVG
58
+
59
+ ```ts
60
+ import { renderAvatarSvg } from "aaavatars/server";
61
+
62
+ const svg = renderAvatarSvg({ name: "Research", size: 128 });
63
+ ```
64
+
65
+ Requires `react-dom`.
66
+
67
+ ```sh
68
+ npm run dev # playground
69
+ npm test
70
+ npm run build
71
+ ```
@@ -0,0 +1,37 @@
1
+ import { type ReactNode } from "react";
2
+ import { type AvatarEvent } from "./animations.js";
3
+ import { type RenderMouth } from "./parts.js";
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];
7
+ export type AvatarContent = "face" | "initial";
8
+ export interface AvatarHandle {
9
+ play: (event: AvatarEvent) => Promise<void>;
10
+ }
11
+ export interface AvatarProps {
12
+ accessory?: Accessory;
13
+ background?: string;
14
+ badge?: ReactNode;
15
+ blink?: boolean;
16
+ body?: string;
17
+ className?: string;
18
+ colors?: readonly string[];
19
+ content?: AvatarContent;
20
+ eyes?: Eyes;
21
+ fill?: Fill;
22
+ frame?: false | number;
23
+ interactive?: boolean;
24
+ label?: string;
25
+ look?: boolean;
26
+ motion?: boolean;
27
+ mouth?: Mouth;
28
+ name: string;
29
+ renderMouth?: RenderMouth;
30
+ shading?: number;
31
+ shape?: Shape;
32
+ size?: number;
33
+ src?: string;
34
+ status?: AvatarStatus;
35
+ }
36
+ export declare function frameRadius(frame: false | number | undefined): string | undefined;
37
+ export declare const Avatar: import("react").ForwardRefExoticComponent<AvatarProps & import("react").RefAttributes<AvatarHandle>>;
package/dist/Avatar.js ADDED
@@ -0,0 +1,170 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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";
4
+ import { SHAPE_GEOMETRY } from "./geometry.js";
5
+ import { AccessoryShape, EyePair, MoodMouths, MouthShape, mouthSlot, } from "./parts.js";
6
+ import { createSpec, shadeHex, } from "./spec.js";
7
+ import { AVATAR_CSS } from "./styles.js";
8
+ export const AVATAR_STATUSES = [
9
+ "idle",
10
+ "thinking",
11
+ "working",
12
+ "waiting",
13
+ "error",
14
+ "sleeping",
15
+ ];
16
+ const LOOK_RANGE = 4;
17
+ const DRAG_THRESHOLD = 3;
18
+ const HOLD_MS = 600;
19
+ const SQUISH_LIMIT = 40;
20
+ function prefersReducedMotion() {
21
+ return (typeof window.matchMedia === "function" &&
22
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches);
23
+ }
24
+ function clampFrame(frame) {
25
+ return Math.min(Math.max(frame, 0), 0.5);
26
+ }
27
+ export function frameRadius(frame) {
28
+ if (frame === false)
29
+ return undefined;
30
+ return `${clampFrame(frame ?? 0.5) * 100}%`;
31
+ }
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) {
33
+ const uid = `aaa-${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
34
+ const svgRef = useRef(null);
35
+ const bodyRef = useRef(null);
36
+ const eyesRef = useRef(null);
37
+ const squishRef = useRef(null);
38
+ const running = useRef([]);
39
+ const drag = useRef(null);
40
+ const suppressClick = useRef(false);
41
+ const [imageFailed, setImageFailed] = useState(false);
42
+ useEffect(() => setImageFailed(false), [src]);
43
+ const spec = useMemo(() => createSpec(name, { accessory, background, body, colors, eyes, mouth, shape }), [name, accessory, background, body, colors, eyes, mouth, shape]);
44
+ const geometry = SHAPE_GEOMETRY[spec.shape];
45
+ const framed = frame !== false;
46
+ const radius = framed ? clampFrame(frame) * 100 : 0;
47
+ const showImage = Boolean(src) && !imageFailed;
48
+ const hasMouth = content === "face" && (Boolean(renderMouth) || spec.mouth !== "none");
49
+ const accessoryBehind = spec.accessory === "antenna" || spec.accessory === "sprout";
50
+ const tracksPointer = look && !showImage;
51
+ const slot = mouthSlot(geometry, spec.eyeColor);
52
+ const bodyFill = fill === "gradient" ? `url(#${uid}-fill)` : spec.body;
53
+ const placement = framed
54
+ ? `translate(${50 + spec.offsetX} ${50 + spec.offsetY}) rotate(${spec.rotate}) scale(0.78) translate(-50 -50)`
55
+ : "translate(50 50) scale(0.92) translate(-50 -50)";
56
+ const play = useCallback((event) => {
57
+ for (const animation of running.current)
58
+ animation.cancel();
59
+ const target = bodyRef.current;
60
+ const eyePair = eyesRef.current;
61
+ if (!target ||
62
+ typeof target.animate !== "function" ||
63
+ document.hidden ||
64
+ prefersReducedMotion()) {
65
+ return Promise.resolve();
66
+ }
67
+ const animation = EVENT_ANIMATIONS[event];
68
+ const timing = { duration: animation.duration, easing: "ease-in-out" };
69
+ const root = svgRef.current;
70
+ const rest = root?.querySelector(".aaa-mouth-rest");
71
+ const accessory = root?.querySelector(".aaa-accessory");
72
+ const accessoryName = accessory?.dataset.accessory;
73
+ const accessoryFrames = accessoryName ? animation.accessory?.[accessoryName] : undefined;
74
+ const mood = animation.mood
75
+ ? root?.querySelector(`.aaa-mood-${animation.mood.name}`)
76
+ : undefined;
77
+ const pairs = [
78
+ [target, animation.body],
79
+ [eyePair, animation.eyes],
80
+ [rest, animation.mood ? (animation.mood.rest ?? MOOD_SWAP_REST) : animation.mouth],
81
+ [mood, animation.mood ? (animation.mood.frames ?? MOOD_SWAP_SHOW) : undefined],
82
+ [accessory, accessoryFrames],
83
+ ];
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);
86
+ }, []);
87
+ useImperativeHandle(ref, () => ({ play }), [play]);
88
+ function lookAt(event) {
89
+ const bounds = event.currentTarget.getBoundingClientRect();
90
+ const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2 * LOOK_RANGE;
91
+ const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2 * LOOK_RANGE;
92
+ event.currentTarget.style.setProperty("--aaa-look-x", `${x}px`);
93
+ event.currentTarget.style.setProperty("--aaa-look-y", `${y}px`);
94
+ }
95
+ function resetLook(event) {
96
+ event.currentTarget.style.removeProperty("--aaa-look-x");
97
+ event.currentTarget.style.removeProperty("--aaa-look-y");
98
+ }
99
+ function startDrag(event) {
100
+ event.currentTarget.setPointerCapture?.(event.pointerId);
101
+ const state = {
102
+ held: false,
103
+ holdTimer: undefined,
104
+ moved: false,
105
+ x: event.clientX,
106
+ y: event.clientY,
107
+ };
108
+ state.holdTimer = setTimeout(() => {
109
+ state.held = true;
110
+ void play("success");
111
+ }, HOLD_MS);
112
+ drag.current = state;
113
+ }
114
+ function moveDrag(event) {
115
+ const state = drag.current;
116
+ const squish = squishRef.current;
117
+ if (!state || !squish)
118
+ return;
119
+ const unitsPerPixel = 100 / event.currentTarget.getBoundingClientRect().width;
120
+ const dx = (event.clientX - state.x) * unitsPerPixel;
121
+ const dy = (event.clientY - state.y) * unitsPerPixel;
122
+ if (!state.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD)
123
+ return;
124
+ state.moved = true;
125
+ clearTimeout(state.holdTimer);
126
+ const clampedX = Math.max(-SQUISH_LIMIT, Math.min(SQUISH_LIMIT, dx));
127
+ const clampedY = Math.max(-SQUISH_LIMIT, Math.min(SQUISH_LIMIT, dy));
128
+ const stretchX = 1 + Math.abs(clampedX) / 100 - Math.abs(clampedY) / 250;
129
+ const stretchY = 1 + Math.abs(clampedY) / 100 - Math.abs(clampedX) / 250;
130
+ squish.setAttribute("data-dragging", "");
131
+ squish.style.transform = `translate(${clampedX * 0.3}px, ${clampedY * 0.3}px) scale(${stretchX}, ${stretchY})`;
132
+ }
133
+ function endDrag() {
134
+ const state = drag.current;
135
+ drag.current = null;
136
+ if (!state)
137
+ return;
138
+ clearTimeout(state.holdTimer);
139
+ suppressClick.current = state.moved || state.held;
140
+ const squish = squishRef.current;
141
+ if (squish) {
142
+ squish.removeAttribute("data-dragging");
143
+ squish.style.transform = "";
144
+ }
145
+ }
146
+ function handleClick(event) {
147
+ if (suppressClick.current) {
148
+ suppressClick.current = false;
149
+ return;
150
+ }
151
+ void play(event.detail > 1 ? "surprise" : "acknowledge");
152
+ }
153
+ 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, role: "img", viewBox: "0 0 100 100", width: size, onClick: interactive ? handleClick : undefined, onPointerCancel: interactive ? endDrag : undefined, onPointerDown: interactive ? startDrag : undefined, onPointerLeave: tracksPointer ? resetLook : undefined, onPointerMove: tracksPointer || interactive
154
+ ? (event) => {
155
+ if (tracksPointer)
156
+ lookAt(event);
157
+ if (interactive)
158
+ moveDrag(event);
159
+ }
160
+ : 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", 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] }) }) }) })) })] }));
161
+ if (!badge)
162
+ return svg;
163
+ return (_jsxs("span", { className: className, style: {
164
+ display: "inline-block",
165
+ height: size,
166
+ lineHeight: 0,
167
+ position: "relative",
168
+ width: size,
169
+ }, children: [svg, _jsx("span", { "data-badge": "", style: { bottom: 0, position: "absolute", right: 0, transform: "translate(20%, 20%)" }, children: badge })] }));
170
+ });
@@ -0,0 +1,10 @@
1
+ import { type AvatarProps } from "./Avatar.js";
2
+ export interface AvatarGroupProps {
3
+ className?: string;
4
+ max?: number;
5
+ members: readonly Omit<AvatarProps, "size">[];
6
+ overlap?: number;
7
+ ringColor?: string;
8
+ size?: number;
9
+ }
10
+ export declare function AvatarGroup({ className, max, members, overlap, ringColor, size, }: AvatarGroupProps): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Avatar, frameRadius } from "./Avatar.js";
3
+ export function AvatarGroup({ className, max = 4, members, overlap = 0.3, ringColor = "#FFFFFF", size = 32, }) {
4
+ const visible = members.length > max ? members.slice(0, max - 1) : members;
5
+ const hidden = members.length - visible.length;
6
+ const label = [
7
+ visible.map((member) => member.name).join(", "),
8
+ hidden ? `and ${hidden} more` : "",
9
+ ]
10
+ .filter(Boolean)
11
+ .join(" ");
12
+ const offset = -Math.round(size * overlap);
13
+ return (_jsxs("div", { "aria-label": label, className: className, role: "group", style: { display: "inline-flex" }, children: [visible.map((member, index) => (_jsx("span", { style: {
14
+ borderRadius: frameRadius(member.frame),
15
+ boxShadow: member.frame === false ? undefined : `0 0 0 2px ${ringColor}`,
16
+ display: "inline-flex",
17
+ marginLeft: index === 0 ? 0 : offset,
18
+ zIndex: visible.length - index,
19
+ }, children: _jsx(Avatar, { ...member, size: size }) }, `${member.name}-${index}`))), hidden ? (_jsxs("span", { "data-overflow": "", style: {
20
+ alignItems: "center",
21
+ background: "#F1F2F3",
22
+ borderRadius: "50%",
23
+ boxShadow: `0 0 0 2px ${ringColor}`,
24
+ color: "#333333",
25
+ display: "inline-flex",
26
+ fontFamily: "system-ui, sans-serif",
27
+ fontSize: Math.round(size * 0.36),
28
+ fontWeight: 600,
29
+ height: size,
30
+ justifyContent: "center",
31
+ marginLeft: offset,
32
+ width: size,
33
+ }, children: ["+", hidden] })) : null] }));
34
+ }
@@ -0,0 +1,21 @@
1
+ export declare const AVATAR_EVENTS: readonly ["acknowledge", "handoff", "done", "success", "failure", "surprise", "wake"];
2
+ import type { Mood } from "./parts.js";
3
+ import type { Accessory } from "./spec.js";
4
+ export type AvatarEvent = (typeof AVATAR_EVENTS)[number];
5
+ type AccessoryFrames = Partial<Record<Exclude<Accessory, "none">, Keyframe[]>>;
6
+ interface EventAnimation {
7
+ accessory?: AccessoryFrames;
8
+ body: Keyframe[];
9
+ duration: number;
10
+ eyes: Keyframe[];
11
+ mood?: {
12
+ frames?: Keyframe[];
13
+ name: Mood;
14
+ rest?: Keyframe[];
15
+ };
16
+ mouth?: Keyframe[];
17
+ }
18
+ export declare const MOOD_SWAP_REST: Keyframe[];
19
+ export declare const MOOD_SWAP_SHOW: Keyframe[];
20
+ export declare const EVENT_ANIMATIONS: Record<AvatarEvent, EventAnimation>;
21
+ export {};
@@ -0,0 +1,222 @@
1
+ export const AVATAR_EVENTS = [
2
+ "acknowledge",
3
+ "handoff",
4
+ "done",
5
+ "success",
6
+ "failure",
7
+ "surprise",
8
+ "wake",
9
+ ];
10
+ function hold(transform, from, to) {
11
+ return [
12
+ { transform, offset: from },
13
+ { transform, offset: to },
14
+ ];
15
+ }
16
+ function stalk(frames) {
17
+ return { antenna: frames, sprout: frames };
18
+ }
19
+ export const MOOD_SWAP_REST = [
20
+ { opacity: 0, offset: 0.12 },
21
+ { opacity: 0, offset: 0.88 },
22
+ ];
23
+ export const MOOD_SWAP_SHOW = [
24
+ { opacity: 1, offset: 0.12 },
25
+ { opacity: 1, offset: 0.88 },
26
+ ];
27
+ export const EVENT_ANIMATIONS = {
28
+ acknowledge: {
29
+ mouth: [{ transform: "scaleY(0.5)", offset: 0.35 }],
30
+ accessory: {
31
+ ...stalk([
32
+ { transform: "rotate(-12deg)", offset: 0.3 },
33
+ { transform: "rotate(8deg)", offset: 0.65 },
34
+ ]),
35
+ hat: [{ transform: "translateY(-4px)", offset: 0.35 }],
36
+ glasses: [{ transform: "translateY(-2px)", offset: 0.35 }],
37
+ bowtie: [{ transform: "scale(1.25)", offset: 0.35 }],
38
+ },
39
+ duration: 420,
40
+ body: [
41
+ { transform: "scale(1, 1)" },
42
+ { transform: "scale(1.12, 0.88)" },
43
+ { transform: "scale(0.94, 1.06)" },
44
+ { transform: "scale(1, 1)" },
45
+ ],
46
+ eyes: [{ transform: "scaleY(1)" }, { transform: "scaleY(0.2)" }, { transform: "scaleY(1)" }],
47
+ },
48
+ handoff: {
49
+ mouth: hold("translateX(5px)", 0.35, 0.65),
50
+ accessory: {
51
+ ...stalk(hold("rotate(-15deg)", 0.35, 0.65)),
52
+ hat: hold("translateX(3px) rotate(6deg)", 0.35, 0.65),
53
+ glasses: hold("translateX(4px)", 0.3, 0.7),
54
+ bowtie: hold("rotate(-12deg)", 0.35, 0.65),
55
+ },
56
+ duration: 900,
57
+ body: [
58
+ { transform: "translateX(0)" },
59
+ { transform: "translateX(10px)", offset: 0.35 },
60
+ { transform: "translateX(10px)", offset: 0.65 },
61
+ { transform: "translateX(0)" },
62
+ ],
63
+ eyes: [
64
+ { transform: "translateX(0)" },
65
+ { transform: "translateX(6px)", offset: 0.3 },
66
+ { transform: "translateX(6px)", offset: 0.7 },
67
+ { transform: "translateX(0)" },
68
+ ],
69
+ },
70
+ done: {
71
+ mood: { name: "happy" },
72
+ accessory: {
73
+ antenna: [{ transform: "scaleY(1.25)", offset: 0.3 }],
74
+ sprout: [{ transform: "scale(1.25)", offset: 0.3 }],
75
+ hat: hold("translateY(-3px) rotate(-10deg)", 0.3, 0.7),
76
+ glasses: [{ transform: "translateY(-3px)", offset: 0.3 }],
77
+ bowtie: [{ transform: "scale(1.3)", offset: 0.3 }],
78
+ },
79
+ duration: 700,
80
+ body: [
81
+ { transform: "translateY(0)" },
82
+ { transform: "translateY(-9px)", offset: 0.3 },
83
+ { transform: "translateY(0)", offset: 0.6 },
84
+ { transform: "translateY(-4px)", offset: 0.8 },
85
+ { transform: "translateY(0)" },
86
+ ],
87
+ eyes: [
88
+ { transform: "scaleY(1)" },
89
+ { transform: "scaleY(0.45)", offset: 0.25 },
90
+ { transform: "scaleY(0.45)", offset: 0.75 },
91
+ { transform: "scaleY(1)" },
92
+ ],
93
+ },
94
+ success: {
95
+ mood: {
96
+ name: "happy",
97
+ frames: [
98
+ { opacity: 1, transform: "scale(1.15)", offset: 0.12 },
99
+ { opacity: 1, transform: "scale(1.15)", offset: 0.88 },
100
+ ],
101
+ },
102
+ accessory: {
103
+ antenna: [
104
+ { transform: "rotate(-20deg)", offset: 0.2 },
105
+ { transform: "rotate(20deg)", offset: 0.45 },
106
+ { transform: "rotate(-10deg)", offset: 0.7 },
107
+ ],
108
+ sprout: hold("scale(1.35)", 0.3, 0.7),
109
+ hat: hold("translateY(-4px) rotate(-15deg)", 0.3, 0.7),
110
+ glasses: hold("translateY(-4px)", 0.3, 0.7),
111
+ bowtie: hold("scale(1.35)", 0.3, 0.7),
112
+ },
113
+ duration: 900,
114
+ body: [
115
+ { transform: "translateY(0) rotate(0deg) scale(1)" },
116
+ { transform: "translateY(-9px) rotate(-8deg) scale(1.06)", offset: 0.3 },
117
+ { transform: "translateY(-9px) rotate(8deg) scale(1.06)", offset: 0.55 },
118
+ { transform: "translateY(0) rotate(0deg) scale(1)" },
119
+ ],
120
+ eyes: [
121
+ { transform: "scaleY(1)" },
122
+ { transform: "scaleY(0.35)", offset: 0.2 },
123
+ { transform: "scaleY(0.35)", offset: 0.8 },
124
+ { transform: "scaleY(1)" },
125
+ ],
126
+ },
127
+ failure: {
128
+ mood: { name: "sad" },
129
+ accessory: {
130
+ ...stalk(hold("rotate(30deg)", 0.2, 0.85)),
131
+ hat: hold("translateY(4px) rotate(10deg)", 0.2, 0.85),
132
+ glasses: hold("translateY(3px) rotate(10deg)", 0.2, 0.85),
133
+ bowtie: hold("rotate(20deg)", 0.2, 0.85),
134
+ },
135
+ duration: 700,
136
+ body: [
137
+ { transform: "translateX(0)" },
138
+ { transform: "translateX(-6px)", offset: 0.15 },
139
+ { transform: "translateX(6px)", offset: 0.3 },
140
+ { transform: "translateX(-5px)", offset: 0.45 },
141
+ { transform: "translateX(4px)", offset: 0.6 },
142
+ { transform: "translateX(0)" },
143
+ ],
144
+ eyes: [
145
+ { transform: "translateY(0) scaleY(1)" },
146
+ { transform: "translateY(3px) scaleY(0.6)", offset: 0.2 },
147
+ { transform: "translateY(3px) scaleY(0.6)", offset: 0.8 },
148
+ { transform: "translateY(0) scaleY(1)" },
149
+ ],
150
+ },
151
+ surprise: {
152
+ mood: {
153
+ name: "o",
154
+ frames: [
155
+ { opacity: 1, transform: "scale(1.3)", offset: 0.12 },
156
+ { opacity: 1, transform: "scale(1.3)", offset: 0.75 },
157
+ { opacity: 0, offset: 0.9 },
158
+ ],
159
+ rest: [
160
+ { opacity: 0, offset: 0.12 },
161
+ { opacity: 0, offset: 0.8 },
162
+ ],
163
+ },
164
+ accessory: {
165
+ antenna: hold("scaleY(1.4)", 0.2, 0.7),
166
+ sprout: hold("scale(1.3)", 0.2, 0.7),
167
+ hat: hold("translateY(-5px)", 0.2, 0.6),
168
+ glasses: hold("translateY(-5px)", 0.2, 0.7),
169
+ bowtie: hold("scale(1.3)", 0.2, 0.7),
170
+ },
171
+ duration: 650,
172
+ body: [
173
+ { transform: "translateY(0) scale(1)" },
174
+ { transform: "translateY(-8px) scale(1.04, 1.1)", offset: 0.25 },
175
+ { transform: "translateY(0) scale(0.97, 1.02)", offset: 0.6 },
176
+ { transform: "translateY(0) scale(1)" },
177
+ ],
178
+ eyes: [
179
+ { transform: "scale(1)" },
180
+ { transform: "scale(1.45)", offset: 0.2 },
181
+ { transform: "scale(1.45)", offset: 0.7 },
182
+ { transform: "scale(1)" },
183
+ ],
184
+ },
185
+ wake: {
186
+ mood: {
187
+ name: "o",
188
+ frames: [
189
+ { opacity: 1, offset: 0.1 },
190
+ { opacity: 1, transform: "scale(1.2)", offset: 0.45 },
191
+ { opacity: 0, offset: 0.65 },
192
+ ],
193
+ rest: [
194
+ { opacity: 0, offset: 0.1 },
195
+ { opacity: 0, offset: 0.6 },
196
+ ],
197
+ },
198
+ accessory: {
199
+ ...stalk([
200
+ { transform: "rotate(-35deg)", offset: 0.05 },
201
+ { transform: "rotate(-35deg)", offset: 0.35 },
202
+ { transform: "rotate(10deg)", offset: 0.7 },
203
+ ]),
204
+ hat: hold("translateY(5px)", 0.05, 0.35),
205
+ glasses: hold("translateY(4px)", 0.05, 0.35),
206
+ bowtie: hold("rotate(15deg)", 0.05, 0.35),
207
+ },
208
+ duration: 800,
209
+ body: [
210
+ { transform: "scale(1, 1)" },
211
+ { transform: "scale(0.96, 1.1)", offset: 0.4 },
212
+ { transform: "scale(1.02, 0.98)", offset: 0.7 },
213
+ { transform: "scale(1, 1)" },
214
+ ],
215
+ eyes: [
216
+ { transform: "scaleY(0.12)" },
217
+ { transform: "scaleY(0.12)", offset: 0.3 },
218
+ { transform: "scaleY(1.2)", offset: 0.6 },
219
+ { transform: "scaleY(1)" },
220
+ ],
221
+ },
222
+ };
@@ -0,0 +1,9 @@
1
+ import type { Shape } from "./spec.js";
2
+ export interface ShapeGeometry {
3
+ bottom: number;
4
+ eyeGap: number;
5
+ eyeY: number;
6
+ path: string;
7
+ top: number;
8
+ }
9
+ export declare const SHAPE_GEOMETRY: Record<Shape, ShapeGeometry>;
@@ -0,0 +1,58 @@
1
+ export const SHAPE_GEOMETRY = {
2
+ circle: {
3
+ path: "M50 10a40 40 0 1 0 0.01 0Z",
4
+ eyeY: 46,
5
+ eyeGap: 16,
6
+ top: 10,
7
+ bottom: 90,
8
+ },
9
+ squircle: {
10
+ path: "M50 10C82 10 90 18 90 50C90 82 82 90 50 90C18 90 10 82 10 50C10 18 18 10 50 10Z",
11
+ eyeY: 46,
12
+ eyeGap: 17,
13
+ top: 10,
14
+ bottom: 90,
15
+ },
16
+ pill: {
17
+ path: "M30 24H70A26 26 0 0 1 70 76H30A26 26 0 0 1 30 24Z",
18
+ eyeY: 48,
19
+ eyeGap: 17,
20
+ top: 24,
21
+ bottom: 76,
22
+ },
23
+ hex: {
24
+ path: "M50 10L85 30V70L50 90L15 70V30Z",
25
+ eyeY: 48,
26
+ eyeGap: 16,
27
+ top: 10,
28
+ bottom: 90,
29
+ },
30
+ triangle: {
31
+ path: "M50 14L89 84H11Z",
32
+ eyeY: 62,
33
+ eyeGap: 12,
34
+ top: 14,
35
+ bottom: 84,
36
+ },
37
+ drop: {
38
+ path: "M50 8C50 8 84 44 84 63C84 82 69 92 50 92C31 92 16 82 16 63C16 44 50 8 50 8Z",
39
+ eyeY: 62,
40
+ eyeGap: 14,
41
+ top: 8,
42
+ bottom: 92,
43
+ },
44
+ cloud: {
45
+ path: "M28 82C14 82 8 70 12 60C4 48 14 34 28 38C30 22 48 16 58 26C70 16 88 26 84 42C96 46 96 66 86 72C86 80 78 84 70 82Z",
46
+ eyeY: 56,
47
+ eyeGap: 16,
48
+ top: 20,
49
+ bottom: 82,
50
+ },
51
+ blob: {
52
+ path: "M54 10C76 12 92 30 88 54C85 76 66 92 44 88C22 85 8 66 12 44C16 22 32 8 54 10Z",
53
+ eyeY: 47,
54
+ eyeGap: 16,
55
+ top: 10,
56
+ bottom: 89,
57
+ },
58
+ };
@@ -0,0 +1,6 @@
1
+ export { AVATAR_EVENTS, type AvatarEvent } from "./animations.js";
2
+ export { AVATAR_STATUSES, Avatar, type AvatarContent, type AvatarHandle, type AvatarProps, type AvatarStatus, } from "./Avatar.js";
3
+ export { AvatarGroup, type AvatarGroupProps } from "./AvatarGroup.js";
4
+ export type { MouthSlot, RenderMouth } from "./parts.js";
5
+ export { ACCESSORIES, type Accessory, type AvatarSpec, DEFAULT_COLORS, EYES, type Eyes, FILLS, type Fill, MOUTHS, type Mouth, SHAPES, type Shape, type SpecOptions, createSpec, } from "./spec.js";
6
+ export { AVATAR_VALUE_MAX_LENGTH, type AvatarValue, type ParsedAvatarValue, formatAvatarValue, parseAvatarValue, } from "./value.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { AVATAR_EVENTS } from "./animations.js";
2
+ export { AVATAR_STATUSES, Avatar, } from "./Avatar.js";
3
+ export { AvatarGroup } from "./AvatarGroup.js";
4
+ export { ACCESSORIES, DEFAULT_COLORS, EYES, FILLS, MOUTHS, SHAPES, createSpec, } from "./spec.js";
5
+ export { AVATAR_VALUE_MAX_LENGTH, formatAvatarValue, parseAvatarValue, } from "./value.js";
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ShapeGeometry } from "./geometry.js";
3
+ import type { Accessory, Eyes, Mouth } from "./spec.js";
4
+ export interface MouthSlot {
5
+ color: string;
6
+ width: number;
7
+ x: number;
8
+ y: number;
9
+ }
10
+ export type RenderMouth = (mouth: MouthSlot) => ReactNode;
11
+ export declare function mouthSlot(geometry: ShapeGeometry, color: string): MouthSlot;
12
+ export declare function EyePair({ eyes, gap, y }: {
13
+ eyes: Eyes;
14
+ gap: number;
15
+ y: number;
16
+ }): import("react").JSX.Element;
17
+ export declare function MouthShape({ mouth, slot }: {
18
+ mouth: Mouth;
19
+ slot: MouthSlot;
20
+ }): import("react").JSX.Element | null;
21
+ export declare const MOODS: readonly ["happy", "sad", "o", "small"];
22
+ export type Mood = (typeof MOODS)[number];
23
+ export declare function MoodMouths({ slot }: {
24
+ slot: MouthSlot;
25
+ }): import("react").JSX.Element;
26
+ interface AccessoryProps {
27
+ accent: string;
28
+ accessory: Accessory;
29
+ body: string;
30
+ geometry: ShapeGeometry;
31
+ }
32
+ export declare function AccessoryShape(props: AccessoryProps): import("react").JSX.Element | null;
33
+ export {};
package/dist/parts.js ADDED
@@ -0,0 +1,65 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const STROKE = { fill: "none", strokeLinecap: "round", strokeWidth: 4 };
3
+ export function mouthSlot(geometry, color) {
4
+ return { color, width: geometry.eyeGap * 1.2, x: 50, y: geometry.eyeY + 17 };
5
+ }
6
+ export function EyePair({ eyes, gap, y }) {
7
+ return (_jsx(_Fragment, { children: [50 - gap, 50 + gap].map((x) => {
8
+ if (eyes === "dot")
9
+ return _jsx("circle", { cx: x, cy: y, r: 7 }, x);
10
+ if (eyes === "tall") {
11
+ return _jsx("rect", { x: x - 5, y: y - 11, width: 10, height: 22, rx: 5 }, x);
12
+ }
13
+ if (eyes === "wide")
14
+ return _jsx("ellipse", { cx: x, cy: y, rx: 11, ry: 7 }, x);
15
+ return _jsx("ellipse", { cx: x, cy: y, rx: 8, ry: 10 }, x);
16
+ }) }));
17
+ }
18
+ export function MouthShape({ mouth, slot }) {
19
+ const half = slot.width / 2;
20
+ if (mouth === "smile") {
21
+ return (_jsx("path", { d: `M${slot.x - half} ${slot.y - 2}Q${slot.x} ${slot.y + 7} ${slot.x + half} ${slot.y - 2}`, stroke: slot.color, ...STROKE }));
22
+ }
23
+ if (mouth === "flat") {
24
+ return (_jsx("path", { d: `M${slot.x - half * 0.7} ${slot.y}H${slot.x + half * 0.7}`, stroke: slot.color, ...STROKE }));
25
+ }
26
+ if (mouth === "open") {
27
+ return _jsx("ellipse", { cx: slot.x, cy: slot.y, rx: half * 0.5, ry: 5, fill: slot.color });
28
+ }
29
+ return null;
30
+ }
31
+ export const MOODS = ["happy", "sad", "o", "small"];
32
+ export function MoodMouths({ slot }) {
33
+ const half = slot.width / 2;
34
+ const { color, x, y } = slot;
35
+ return (_jsxs(_Fragment, { children: [_jsx("g", { className: "aaa-mood aaa-mood-happy", children: _jsx("path", { d: `M${x - half} ${y - 3}Q${x} ${y + 12} ${x + half} ${y - 3}Z`, fill: color }) }), _jsx("g", { className: "aaa-mood aaa-mood-sad", children: _jsx("path", { d: `M${x - half * 0.8} ${y + 4}Q${x} ${y - 5} ${x + half * 0.8} ${y + 4}`, stroke: color, ...STROKE }) }), _jsx("g", { className: "aaa-mood aaa-mood-o", children: _jsx("ellipse", { cx: x, cy: y + 1, rx: 5, ry: 6.5, fill: color }) }), _jsx("g", { className: "aaa-mood aaa-mood-small", children: _jsx("path", { d: `M${x + 1} ${y}H${x + 9}`, stroke: color, ...STROKE }) })] }));
36
+ }
37
+ export function AccessoryShape(props) {
38
+ if (props.accessory === "none")
39
+ return null;
40
+ return (_jsx("g", { className: "aaa-accessory", "data-accessory": props.accessory, children: _jsx(AccessoryArt, { ...props }) }));
41
+ }
42
+ function AccessoryArt({ accessory, accent, body, geometry }) {
43
+ const top = geometry.top - 4;
44
+ const bottom = geometry.bottom;
45
+ if (accessory === "antenna") {
46
+ return (_jsxs("g", { children: [_jsx("path", { d: `M50 ${top}V${top - 9}`, stroke: body, ...STROKE, strokeWidth: 5 }), _jsx("circle", { className: "aaa-antenna-tip", cx: 50, cy: top - 13, r: 6, fill: accent })] }));
47
+ }
48
+ if (accessory === "sprout") {
49
+ return (_jsxs("g", { fill: accent, children: [_jsx("path", { d: `M50 ${top + 2}V${top - 10}`, stroke: accent, ...STROKE }), _jsx("ellipse", { cx: 42, cy: top - 12, rx: 9, ry: 5, transform: `rotate(-30 42 ${top - 12})` }), _jsx("ellipse", { cx: 58, cy: top - 14, rx: 9, ry: 5, transform: `rotate(25 58 ${top - 14})` })] }));
50
+ }
51
+ if (accessory === "hat") {
52
+ return (_jsxs("g", { fill: accent, children: [_jsx("rect", { x: 32, y: top - 14, width: 36, height: 16, rx: 5 }), _jsx("rect", { x: 24, y: top - 2, width: 52, height: 7, rx: 3.5 })] }));
53
+ }
54
+ if (accessory === "glasses") {
55
+ const radius = 13;
56
+ const left = 50 - geometry.eyeGap;
57
+ const right = 50 + geometry.eyeGap;
58
+ return (_jsxs("g", { stroke: accent, fill: "none", strokeWidth: 3.5, children: [_jsx("circle", { cx: left, cy: geometry.eyeY, r: radius }), _jsx("circle", { cx: right, cy: geometry.eyeY, r: radius }), _jsx("path", { d: `M${left + radius} ${geometry.eyeY}H${right - radius}` })] }));
59
+ }
60
+ if (accessory === "bowtie") {
61
+ const y = bottom - 6;
62
+ return (_jsxs("g", { fill: accent, children: [_jsx("path", { d: `M50 ${y}L37 ${y - 8}V${y + 8}Z` }), _jsx("path", { d: `M50 ${y}L63 ${y - 8}V${y + 8}Z` }), _jsx("circle", { cx: 50, cy: y, r: 4 })] }));
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,3 @@
1
+ import { type AvatarProps } from "./Avatar.js";
2
+ export type StaticAvatarProps = Omit<AvatarProps, "badge" | "interactive" | "renderMouth">;
3
+ export declare function renderAvatarSvg(props: StaticAvatarProps): string;
package/dist/server.js ADDED
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { Avatar } from "./Avatar.js";
4
+ export function renderAvatarSvg(props) {
5
+ return renderToStaticMarkup(_jsx(Avatar, { ...props }));
6
+ }
package/dist/spec.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ export declare const SHAPES: readonly ["circle", "squircle", "pill", "hex", "triangle", "drop", "cloud", "blob"];
2
+ export declare const EYES: readonly ["round", "dot", "tall", "wide"];
3
+ export declare const MOUTHS: readonly ["none", "smile", "flat", "open"];
4
+ export declare const ACCESSORIES: readonly ["none", "antenna", "sprout", "hat", "glasses", "bowtie"];
5
+ export declare const FILLS: readonly ["solid", "gradient"];
6
+ export declare const DEFAULT_COLORS: string[];
7
+ export type Shape = (typeof SHAPES)[number];
8
+ export type Eyes = (typeof EYES)[number];
9
+ export type Mouth = (typeof MOUTHS)[number];
10
+ export type Accessory = (typeof ACCESSORIES)[number];
11
+ export type Fill = (typeof FILLS)[number];
12
+ export interface AvatarSpec {
13
+ accent: string;
14
+ accessory: Accessory;
15
+ background: string;
16
+ body: string;
17
+ eyeColor: string;
18
+ eyes: Eyes;
19
+ mouth: Mouth;
20
+ offsetX: number;
21
+ offsetY: number;
22
+ rotate: number;
23
+ shape: Shape;
24
+ }
25
+ export interface SpecOptions {
26
+ accessory?: Accessory;
27
+ background?: string;
28
+ body?: string;
29
+ colors?: readonly string[];
30
+ eyes?: Eyes;
31
+ mouth?: Mouth;
32
+ shape?: Shape;
33
+ }
34
+ export declare function createSpec(name: string, options?: SpecOptions): AvatarSpec;
35
+ export declare function shadeHex(hex: string, amount: number): string;
package/dist/spec.js ADDED
@@ -0,0 +1,102 @@
1
+ export const SHAPES = [
2
+ "circle",
3
+ "squircle",
4
+ "pill",
5
+ "hex",
6
+ "triangle",
7
+ "drop",
8
+ "cloud",
9
+ "blob",
10
+ ];
11
+ export const EYES = ["round", "dot", "tall", "wide"];
12
+ export const MOUTHS = ["none", "smile", "flat", "open"];
13
+ export const ACCESSORIES = ["none", "antenna", "sprout", "hat", "glasses", "bowtie"];
14
+ export const FILLS = ["solid", "gradient"];
15
+ const HASHED_MOUTHS = ["none", "none", "none", "smile", "flat", "open"];
16
+ const HASHED_ACCESSORIES = [
17
+ "none",
18
+ "none",
19
+ "none",
20
+ "none",
21
+ "antenna",
22
+ "sprout",
23
+ "hat",
24
+ "glasses",
25
+ "bowtie",
26
+ ];
27
+ export const DEFAULT_COLORS = ["#264653", "#2A9D8F", "#E9C46A", "#F4A261", "#E76F51"];
28
+ function hash(value) {
29
+ let result = 2166136261;
30
+ for (let index = 0; index < value.length; index += 1) {
31
+ result = Math.imul(result ^ value.charCodeAt(index), 16777619);
32
+ }
33
+ return result >>> 0;
34
+ }
35
+ function seededRandom(seed) {
36
+ let state = seed;
37
+ return () => {
38
+ state = (state + 0x6d2b79f5) | 0;
39
+ let next = Math.imul(state ^ (state >>> 15), 1 | state);
40
+ next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
41
+ return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
42
+ };
43
+ }
44
+ function pick(items, random) {
45
+ return items[Math.floor(random() * items.length)];
46
+ }
47
+ function signedRange(random, limit) {
48
+ return Math.round((random() * 2 - 1) * limit);
49
+ }
50
+ function relativeLuminance(hex) {
51
+ const digits = hex.replace("#", "");
52
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
53
+ const [red, green, blue] = [0, 2, 4].map((start) => {
54
+ const channel = Number.parseInt(full.slice(start, start + 2), 16) / 255;
55
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
56
+ });
57
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
58
+ }
59
+ export function createSpec(name, options = {}) {
60
+ const colors = options.colors?.length ? options.colors : DEFAULT_COLORS;
61
+ const random = seededRandom(hash(name));
62
+ const backgroundIndex = Math.floor(random() * colors.length);
63
+ const bodyShift = 1 + Math.floor(random() * Math.max(colors.length - 1, 1));
64
+ const bodyIndex = (backgroundIndex + bodyShift) % colors.length;
65
+ const shape = pick(SHAPES, random);
66
+ const eyes = pick(EYES, random);
67
+ const offsetX = signedRange(random, 6);
68
+ const offsetY = signedRange(random, 5);
69
+ const rotate = signedRange(random, 12);
70
+ const mouth = pick(HASHED_MOUTHS, random);
71
+ const accessory = pick(HASHED_ACCESSORIES, random);
72
+ const accentCandidates = colors.filter((_, index) => index !== bodyIndex && index !== backgroundIndex);
73
+ const accent = accentCandidates.length ? pick(accentCandidates, random) : colors[0];
74
+ const background = options.background ?? colors[backgroundIndex];
75
+ const body = options.body ?? colors[bodyIndex];
76
+ return {
77
+ accent,
78
+ accessory: options.accessory ?? accessory,
79
+ background,
80
+ body,
81
+ eyeColor: relativeLuminance(body) > 0.4 ? "#141414" : "#FFFFFF",
82
+ eyes: options.eyes ?? eyes,
83
+ mouth: options.mouth ?? mouth,
84
+ offsetX,
85
+ offsetY,
86
+ rotate,
87
+ shape: options.shape ?? shape,
88
+ };
89
+ }
90
+ export function shadeHex(hex, amount) {
91
+ const digits = hex.replace("#", "");
92
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
93
+ const target = amount < 0 ? 0 : 255;
94
+ const weight = Math.abs(amount);
95
+ const channels = [0, 2, 4].map((start) => {
96
+ const channel = Number.parseInt(full.slice(start, start + 2), 16);
97
+ return Math.round(channel + (target - channel) * weight)
98
+ .toString(16)
99
+ .padStart(2, "0");
100
+ });
101
+ return `#${channels.join("")}`;
102
+ }
@@ -0,0 +1 @@
1
+ export declare const AVATAR_CSS = "\n.aaa-body-motion,.aaa-react,.aaa-eyes-react,.aaa-eyes-motion,.aaa-blink,.aaa-squish{transform-box:fill-box;transform-origin:center}\n.aaa-mouth-rest,.aaa-mood{transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .2s}\n.aaa-mood{opacity:0}\n.aaa-accessory{transform-box:fill-box;transform-origin:50% 100%;transition:transform .3s ease-out}\n.aaa-accessory[data-accessory=glasses],.aaa-accessory[data-accessory=bowtie]{transform-origin:center}\n.aaa-squish{transition:transform .45s cubic-bezier(.34,1.56,.64,1)}\n.aaa-squish[data-dragging]{transition:none}\n.aaa[data-interactive]{cursor:grab;touch-action:none}\n.aaa[data-interactive]:active{cursor:grabbing}\n.aaa-look{transform:translate(var(--aaa-look-x,0px),var(--aaa-look-y,0px));transition:transform .25s ease-out}\n.aaa-blink{animation:aaa-blink 4.6s infinite}\n.aaa[data-status=idle] .aaa-body-motion{animation:aaa-bob 3.6s ease-in-out infinite}\n.aaa[data-status=thinking] .aaa-body-motion{animation:aaa-sway 3s ease-in-out infinite}\n.aaa[data-status=thinking] .aaa-eyes-motion{transform:translate(-5px,-6px);animation:aaa-ponder 2.4s ease-in-out infinite}\n.aaa[data-status=working] .aaa-body-motion{animation:aaa-bob .9s ease-in-out infinite}\n.aaa[data-status=working] .aaa-eyes-motion{animation:aaa-scan 1.1s ease-in-out infinite}\n.aaa[data-status=waiting] .aaa-body-motion{animation:aaa-hop 1.6s ease-out infinite}\n.aaa[data-status=waiting] .aaa-eyes-motion{transform:scale(1.18)}\n.aaa[data-status=error] .aaa-body-motion{transform:rotate(-10deg)}\n.aaa[data-status=error] .aaa-eyes-motion{transform:scaleY(.25)}\n.aaa[data-status=error] .aaa-blink{animation:none}\n.aaa[data-status=sleeping] .aaa-body-motion{animation:aaa-breathe 4.2s ease-in-out infinite}\n.aaa[data-status=sleeping] .aaa-eyes-motion{transform:translateY(2px) scaleY(.12)}\n.aaa[data-status=sleeping] .aaa-blink{animation:none}\n.aaa[data-status=working] .aaa-antenna-tip{animation:aaa-signal .9s ease-in-out infinite}\n.aaa[data-status=thinking] .aaa-mouth-rest{opacity:0}\n.aaa[data-status=thinking] .aaa-mood-small{opacity:1}\n.aaa[data-status=thinking] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){animation:aaa-acc-sway 2.4s ease-in-out infinite}\n.aaa[data-status=thinking] .aaa-accessory[data-accessory=glasses]{animation:aaa-glasses-think 2.4s ease-in-out infinite}\n.aaa[data-status=working] .aaa-mouth-rest{transform:scaleX(.7)}\n.aaa[data-status=working] .aaa-accessory[data-accessory=bowtie]{animation:aaa-wobble .9s ease-in-out infinite}\n.aaa[data-status=waiting] .aaa-mouth-rest{opacity:0}\n.aaa[data-status=waiting] .aaa-mood-happy{opacity:1}\n.aaa[data-status=waiting] .aaa-accessory:not([data-accessory=glasses]){animation:aaa-wobble 1.6s ease-in-out infinite}\n.aaa[data-status=error] .aaa-mouth-rest{opacity:0}\n.aaa[data-status=error] .aaa-mood-sad{opacity:1}\n.aaa[data-status=error] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){transform:rotate(28deg)}\n.aaa[data-status=error] .aaa-accessory:is([data-accessory=hat],[data-accessory=glasses]){transform:translateY(3px) rotate(8deg)}\n.aaa[data-status=error] .aaa-accessory[data-accessory=bowtie]{transform:rotate(18deg)}\n.aaa[data-status=sleeping] .aaa-mouth-rest{opacity:0}\n.aaa[data-status=sleeping] .aaa-mood-o{opacity:1;animation:aaa-snore 4.2s ease-in-out infinite}\n.aaa[data-status=sleeping] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){transform:rotate(-35deg)}\n.aaa[data-status=sleeping] .aaa-accessory:is([data-accessory=hat],[data-accessory=glasses]){transform:translateY(5px)}\n.aaa[data-blink=off] .aaa-blink{animation:none}\n.aaa[data-motion=off] *{animation:none!important}\n@keyframes aaa-blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.1)}}\n@keyframes aaa-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}\n@keyframes aaa-sway{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}\n@keyframes aaa-ponder{0%,100%{transform:translate(-5px,-6px)}50%{transform:translate(5px,-6px)}}\n@keyframes aaa-scan{0%,100%{transform:translateX(-5px)}50%{transform:translateX(5px)}}\n@keyframes aaa-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.035,.97)}}\n@keyframes aaa-signal{0%,100%{opacity:1}50%{opacity:.25}}\n@keyframes aaa-acc-sway{0%,100%{transform:rotate(-8deg)}50%{transform:rotate(8deg)}}\n@keyframes aaa-wobble{0%,100%{transform:rotate(-12deg)}50%{transform:rotate(12deg)}}\n@keyframes aaa-glasses-think{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}\n@keyframes aaa-snore{0%,100%{transform:scale(.7)}50%{transform:scale(1.1)}}\n@keyframes aaa-hop{0%,55%,100%{transform:translateY(0)}68%{transform:translateY(-7px)}80%{transform:translateY(0)}90%{transform:translateY(-3px)}}\n@media (prefers-reduced-motion:reduce){.aaa *{animation:none!important;transition:none!important}}\n";
package/dist/styles.js ADDED
@@ -0,0 +1,60 @@
1
+ export const AVATAR_CSS = `
2
+ .aaa-body-motion,.aaa-react,.aaa-eyes-react,.aaa-eyes-motion,.aaa-blink,.aaa-squish{transform-box:fill-box;transform-origin:center}
3
+ .aaa-mouth-rest,.aaa-mood{transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .2s}
4
+ .aaa-mood{opacity:0}
5
+ .aaa-accessory{transform-box:fill-box;transform-origin:50% 100%;transition:transform .3s ease-out}
6
+ .aaa-accessory[data-accessory=glasses],.aaa-accessory[data-accessory=bowtie]{transform-origin:center}
7
+ .aaa-squish{transition:transform .45s cubic-bezier(.34,1.56,.64,1)}
8
+ .aaa-squish[data-dragging]{transition:none}
9
+ .aaa[data-interactive]{cursor:grab;touch-action:none}
10
+ .aaa[data-interactive]:active{cursor:grabbing}
11
+ .aaa-look{transform:translate(var(--aaa-look-x,0px),var(--aaa-look-y,0px));transition:transform .25s ease-out}
12
+ .aaa-blink{animation:aaa-blink 4.6s infinite}
13
+ .aaa[data-status=idle] .aaa-body-motion{animation:aaa-bob 3.6s ease-in-out infinite}
14
+ .aaa[data-status=thinking] .aaa-body-motion{animation:aaa-sway 3s ease-in-out infinite}
15
+ .aaa[data-status=thinking] .aaa-eyes-motion{transform:translate(-5px,-6px);animation:aaa-ponder 2.4s ease-in-out infinite}
16
+ .aaa[data-status=working] .aaa-body-motion{animation:aaa-bob .9s ease-in-out infinite}
17
+ .aaa[data-status=working] .aaa-eyes-motion{animation:aaa-scan 1.1s ease-in-out infinite}
18
+ .aaa[data-status=waiting] .aaa-body-motion{animation:aaa-hop 1.6s ease-out infinite}
19
+ .aaa[data-status=waiting] .aaa-eyes-motion{transform:scale(1.18)}
20
+ .aaa[data-status=error] .aaa-body-motion{transform:rotate(-10deg)}
21
+ .aaa[data-status=error] .aaa-eyes-motion{transform:scaleY(.25)}
22
+ .aaa[data-status=error] .aaa-blink{animation:none}
23
+ .aaa[data-status=sleeping] .aaa-body-motion{animation:aaa-breathe 4.2s ease-in-out infinite}
24
+ .aaa[data-status=sleeping] .aaa-eyes-motion{transform:translateY(2px) scaleY(.12)}
25
+ .aaa[data-status=sleeping] .aaa-blink{animation:none}
26
+ .aaa[data-status=working] .aaa-antenna-tip{animation:aaa-signal .9s ease-in-out infinite}
27
+ .aaa[data-status=thinking] .aaa-mouth-rest{opacity:0}
28
+ .aaa[data-status=thinking] .aaa-mood-small{opacity:1}
29
+ .aaa[data-status=thinking] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){animation:aaa-acc-sway 2.4s ease-in-out infinite}
30
+ .aaa[data-status=thinking] .aaa-accessory[data-accessory=glasses]{animation:aaa-glasses-think 2.4s ease-in-out infinite}
31
+ .aaa[data-status=working] .aaa-mouth-rest{transform:scaleX(.7)}
32
+ .aaa[data-status=working] .aaa-accessory[data-accessory=bowtie]{animation:aaa-wobble .9s ease-in-out infinite}
33
+ .aaa[data-status=waiting] .aaa-mouth-rest{opacity:0}
34
+ .aaa[data-status=waiting] .aaa-mood-happy{opacity:1}
35
+ .aaa[data-status=waiting] .aaa-accessory:not([data-accessory=glasses]){animation:aaa-wobble 1.6s ease-in-out infinite}
36
+ .aaa[data-status=error] .aaa-mouth-rest{opacity:0}
37
+ .aaa[data-status=error] .aaa-mood-sad{opacity:1}
38
+ .aaa[data-status=error] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){transform:rotate(28deg)}
39
+ .aaa[data-status=error] .aaa-accessory:is([data-accessory=hat],[data-accessory=glasses]){transform:translateY(3px) rotate(8deg)}
40
+ .aaa[data-status=error] .aaa-accessory[data-accessory=bowtie]{transform:rotate(18deg)}
41
+ .aaa[data-status=sleeping] .aaa-mouth-rest{opacity:0}
42
+ .aaa[data-status=sleeping] .aaa-mood-o{opacity:1;animation:aaa-snore 4.2s ease-in-out infinite}
43
+ .aaa[data-status=sleeping] .aaa-accessory:is([data-accessory=antenna],[data-accessory=sprout]){transform:rotate(-35deg)}
44
+ .aaa[data-status=sleeping] .aaa-accessory:is([data-accessory=hat],[data-accessory=glasses]){transform:translateY(5px)}
45
+ .aaa[data-blink=off] .aaa-blink{animation:none}
46
+ .aaa[data-motion=off] *{animation:none!important}
47
+ @keyframes aaa-blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.1)}}
48
+ @keyframes aaa-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
49
+ @keyframes aaa-sway{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}
50
+ @keyframes aaa-ponder{0%,100%{transform:translate(-5px,-6px)}50%{transform:translate(5px,-6px)}}
51
+ @keyframes aaa-scan{0%,100%{transform:translateX(-5px)}50%{transform:translateX(5px)}}
52
+ @keyframes aaa-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.035,.97)}}
53
+ @keyframes aaa-signal{0%,100%{opacity:1}50%{opacity:.25}}
54
+ @keyframes aaa-acc-sway{0%,100%{transform:rotate(-8deg)}50%{transform:rotate(8deg)}}
55
+ @keyframes aaa-wobble{0%,100%{transform:rotate(-12deg)}50%{transform:rotate(12deg)}}
56
+ @keyframes aaa-glasses-think{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
57
+ @keyframes aaa-snore{0%,100%{transform:scale(.7)}50%{transform:scale(1.1)}}
58
+ @keyframes aaa-hop{0%,55%,100%{transform:translateY(0)}68%{transform:translateY(-7px)}80%{transform:translateY(0)}90%{transform:translateY(-3px)}}
59
+ @media (prefers-reduced-motion:reduce){.aaa *{animation:none!important;transition:none!important}}
60
+ `;
@@ -0,0 +1,20 @@
1
+ import { type Accessory, type Eyes, type Fill, type Mouth, type Shape } from "./spec.js";
2
+ export interface AvatarValue {
3
+ accessory?: Accessory;
4
+ background?: string;
5
+ body?: string;
6
+ eyes?: Eyes;
7
+ fill?: Fill;
8
+ mouth?: Mouth;
9
+ shape?: Shape;
10
+ }
11
+ export type ParsedAvatarValue = {
12
+ ok: true;
13
+ value: AvatarValue;
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export declare const AVATAR_VALUE_MAX_LENGTH = 200;
19
+ export declare function parseAvatarValue(raw: string): ParsedAvatarValue;
20
+ export declare function formatAvatarValue(value: AvatarValue): string;
package/dist/value.js ADDED
@@ -0,0 +1,66 @@
1
+ import { ACCESSORIES, EYES, FILLS, MOUTHS, SHAPES, } from "./spec.js";
2
+ export const AVATAR_VALUE_MAX_LENGTH = 200;
3
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
4
+ const ENUM_FIELDS = {
5
+ shape: SHAPES,
6
+ eyes: EYES,
7
+ mouth: MOUTHS,
8
+ accessory: ACCESSORIES,
9
+ fill: FILLS,
10
+ };
11
+ const COLOR_FIELDS = ["body", "background"];
12
+ const FIELD_ORDER = [
13
+ "shape",
14
+ "eyes",
15
+ "mouth",
16
+ "accessory",
17
+ "body",
18
+ "background",
19
+ "fill",
20
+ ];
21
+ function isEnumField(key) {
22
+ return key in ENUM_FIELDS;
23
+ }
24
+ function isColorField(key) {
25
+ return COLOR_FIELDS.includes(key);
26
+ }
27
+ export function parseAvatarValue(raw) {
28
+ if (raw.length > AVATAR_VALUE_MAX_LENGTH) {
29
+ return {
30
+ ok: false,
31
+ error: `Avatar value is longer than ${AVATAR_VALUE_MAX_LENGTH} characters.`,
32
+ };
33
+ }
34
+ const value = {};
35
+ for (const entry of raw
36
+ .split(";")
37
+ .map((part) => part.trim())
38
+ .filter(Boolean)) {
39
+ const separator = entry.indexOf("=");
40
+ const key = separator === -1 ? entry : entry.slice(0, separator).trim();
41
+ const field = separator === -1 ? "" : entry.slice(separator + 1).trim();
42
+ if (key in value)
43
+ return { ok: false, error: `"${key}" is set more than once.` };
44
+ if (isEnumField(key)) {
45
+ const allowed = ENUM_FIELDS[key];
46
+ if (!allowed.includes(field)) {
47
+ return { ok: false, error: `"${key}" must be one of ${allowed.join(", ")}.` };
48
+ }
49
+ }
50
+ else if (isColorField(key)) {
51
+ if (!HEX_COLOR.test(field)) {
52
+ return { ok: false, error: `"${key}" must be a hex color like #E76F51.` };
53
+ }
54
+ }
55
+ else {
56
+ return { ok: false, error: `Unknown avatar field "${key}". Use ${FIELD_ORDER.join(", ")}.` };
57
+ }
58
+ value[key] = field;
59
+ }
60
+ return { ok: true, value: value };
61
+ }
62
+ export function formatAvatarValue(value) {
63
+ return FIELD_ORDER.filter((key) => value[key] !== undefined)
64
+ .map((key) => `${key}=${value[key]}`)
65
+ .join(";");
66
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "aaavatars",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Deterministic 2D character avatars with a play() animation API",
5
+ "keywords": [
6
+ "avatar",
7
+ "react",
8
+ "svg",
9
+ "ai-agent",
10
+ "agent-avatar",
11
+ "animation",
12
+ "identicon",
13
+ "generative",
14
+ "deterministic"
15
+ ],
16
+ "author": "Rudolf Schmidt",
17
+ "license": "MIT",
18
+ "homepage": "https://rsmdt.github.io/aaavatars/",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/rsmdt/aaavatars.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/rsmdt/aaavatars/issues"
25
+ },
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js"
37
+ },
38
+ "./server": {
39
+ "types": "./dist/server.d.ts",
40
+ "import": "./dist/server.js"
41
+ }
42
+ },
43
+ "scripts": {
44
+ "dev": "vite",
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "build:demo": "vite build --base=/aaavatars/ --outDir demo-dist",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
50
+ },
51
+ "peerDependencies": {
52
+ "react": ">=18",
53
+ "react-dom": ">=18"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "react-dom": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@tailwindcss/vite": "^4.3.3",
62
+ "@testing-library/react": "^16.3.3",
63
+ "@types/react": "^19.3.0",
64
+ "@types/react-dom": "^19.3.0",
65
+ "jsdom": "^29.1.1",
66
+ "lucide-react": "^1.57.0",
67
+ "react": "^19.3.1",
68
+ "react-dom": "^19.3.1",
69
+ "tailwindcss": "^4.3.3",
70
+ "typescript": "^7.0.2",
71
+ "vite": "^8.3.4",
72
+ "vitest": "^5.0.4"
73
+ }
74
+ }