Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-question-glyph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agentfaces": minor
---

`needs-you` no longer morphs the body into a question mark. The face stays on screen and shows the state's expressions. The `question` glyph kind and the `QUESTION_HOOK`, `QUESTION_TAIL` and `QUESTION_GROWTH` exports are removed.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![bundle size](https://img.shields.io/bundlejs/size/agentfaces)](https://bundlejs.com/?q=agentfaces)
[![license](https://img.shields.io/npm/l/agentfaces.svg)](./LICENSE)

<p align="center"><img src="https://raw.githubusercontent.com/ajibadedapo/agentfaces/main/assets/demo.gif" alt="Three agent faces moving through idle, thinking, working, needs you, alert, done and celebrate, with the body morphing into a question mark, an exclamation mark and a check mark" width="640"></p>
<p align="center"><img src="https://raw.githubusercontent.com/ajibadedapo/agentfaces/main/assets/demo.gif" alt="Three agent faces moving through idle, thinking, working, needs you, alert, done and celebrate, with the body morphing into an exclamation mark and a check mark" width="640"></p>

**Give your AI agent a face: one component that shows thinking, working, needs you and done at a glance, on web and React Native.**

Expand All @@ -24,7 +24,7 @@ Expressive faces for AI agents. Show what an agent is doing (thinking, working,
![Core agent states](https://raw.githubusercontent.com/ajibadedapo/agentfaces/main/assets/states.png)

- **Agent work states**: idle, thinking, working, needs you, done, alert, celebrate and sleeping, plus ten more.
- **Alive, not busy**: blinks, gaze drift, micro saccades, springy body motion, glyph morphs (the body becomes a `!`, `?` or check mark and back) and confetti on celebrate.
- **Alive, not busy**: blinks, gaze drift, micro saccades, springy body motion, glyph morphs (the body becomes a `!` or check mark and back) and confetti on celebrate.
- **Accessible by default**: every face has an accessible name and respects reduced motion. The still variant runs no timers.
- **Cheap at scale**: one shared ticker drives every face on screen, and off-screen faces pause.
- **Zero runtime dependencies**, tree-shakeable ESM and CJS builds, full TypeScript types.
Expand Down Expand Up @@ -125,7 +125,7 @@ The provider also takes `variant`, `mouth`, `reducedMotion` and `labels` (to tra
| Ornaments | `typing` (ellipsis), `running` (spinner), `monitoring` (ripple), `background` (tracker) |
| Voice | `listening`, `speaking` |

`alert`, `needs-you` and `done` morph the body into `!`, `?` and a check mark. `celebrate` throws confetti. Morphs, ornaments and confetti are skipped under reduced motion and in the still variant.
`alert` and `done` morph the body into `!` and a check mark. `celebrate` throws confetti. Morphs, ornaments and confetti are skipped under reduced motion and in the still variant.

## Voice agents

Expand Down
Binary file modified assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Accessible names (`STATE_LABEL`): Idle, Thinking, Working, Needs you, Done, Aler

Behaviour worth knowing:

- `alert`, `needs-you` and `done` periodically morph the body into `!`, `?` and a check mark and back.
- `alert` and `done` periodically morph the body into `!` and a check mark and back.
- `celebrate` throws confetti.
- `done`, `starting` and `handing-off` play their body motion once; the others loop.
- `listening` leans in and pulses with the user's voice; `speaking` moves the mouth with the agent's audio.
Expand Down
24 changes: 4 additions & 20 deletions src/core/glyph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { FACE_ANCHORS } from "./anchors";
import { EXPRESSIONS } from "./expressions";
import { faceGeometry, REST_POSE, type FacePose } from "./face";
import { bodyOutline, EXPRESSION_GLYPH, GLYPH_POINTS, GLYPH_SCHEDULE, GLYPHS, glyphAnchor, glyphDot, glyphDotStops, glyphFaceOpacity, glyphKindsFor, glyphOutline, GlyphPerformer, morphKeyframes, morphPath, morphTrack, QUESTION_HOOK, STATE_GLYPH, type GlyphKind } from "./glyph";
import { bodyOutline, EXPRESSION_GLYPH, GLYPH_POINTS, GLYPH_SCHEDULE, GLYPHS, glyphAnchor, glyphDot, glyphDotStops, glyphFaceOpacity, glyphKindsFor, glyphOutline, GlyphPerformer, morphKeyframes, morphPath, morphTrack, STATE_GLYPH, type GlyphKind } from "./glyph";
import { framePoints, MORPH_POINTS, polygonArea } from "./morph";
import type { Polygon } from "./path";
import { distanceToOutline, flattenPath, pathNumbers, pointInPolygons } from "./path";
Expand Down Expand Up @@ -53,9 +53,9 @@ function sharpestConvexTurn(poly: Polygon): number {
}

describe("glyph morphs", () => {
it("maps alert, needs you and done to a glyph, and smitten to the heart", () => {
it("maps alert and done to a glyph, keeps needs you as a face, and smitten to the heart", () => {
expect(STATE_GLYPH.alert).toBe("bang");
expect(STATE_GLYPH["needs-you"]).toBe("question");
expect(STATE_GLYPH["needs-you"]).toBeUndefined();
expect(STATE_GLYPH.done).toBe("check");
expect(EXPRESSION_GLYPH.smitten).toBe("heart");
for (const state of AGENT_STATES) expect(Boolean(GLYPH_SCHEDULE[state])).toBe(state in STATE_GLYPH);
Expand Down Expand Up @@ -83,7 +83,7 @@ describe("glyph morphs", () => {
let area = 0;
for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) area += outline[j][0] * outline[i][1] - outline[i][0] * outline[j][1];
expect(Math.abs(area) / 2).toBeGreaterThan(kind === "heart" ? 3000 : 600);
expect(pointInPolygons([outline], 50, kind === "check" ? 68 : kind === "heart" ? 50 : kind === "question" ? 13 : 30)).toBe(true);
expect(pointInPolygons([outline], 50, kind === "check" ? 68 : kind === "heart" ? 50 : 30)).toBe(true);
});

it("resamples every body to the same point count so the morph interpolates point for point", () => {
Expand Down Expand Up @@ -131,22 +131,6 @@ describe("glyph morphs", () => {
}
});

it("builds the question mark from a parametric hook and a tangent-continuous tail", () => {
const outline = glyphOutline("question");
expect(pointInPolygons([outline], QUESTION_HOOK.cx, QUESTION_HOOK.cy)).toBe(false);
expect(pointInPolygons([outline], QUESTION_HOOK.cx + QUESTION_HOOK.r, QUESTION_HOOK.cy)).toBe(true);
expect(pointInPolygons([outline], QUESTION_HOOK.cx, QUESTION_HOOK.cy - QUESTION_HOOK.r)).toBe(true);
const growth = GLYPHS.question.growth!;
const centre = growth.centre;
for (let i = 2; i < centre.length; i++) {
const a = Math.atan2(centre[i - 1][1] - centre[i - 2][1], centre[i - 1][0] - centre[i - 2][0]);
const b = Math.atan2(centre[i][1] - centre[i - 1][1], centre[i][0] - centre[i - 1][0]);
let turn = Math.abs(b - a);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
expect(turn).toBeLessThan(0.2);
}
});

it("hides the face for the marks, keeps it on the heart, and pops the dot late", () => {
expect(glyphFaceOpacity("bang", 0)).toBe(1);
expect(glyphFaceOpacity("bang", 0.15)).toBeCloseTo(0.5, 5);
Expand Down
58 changes: 2 additions & 56 deletions src/core/glyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { stepSprings, type SpringConfig } from "./spring";
import { springFromFeel, tempo } from "./tempo";
import type { AgentState } from "./states";

export type GlyphKind = "bang" | "question" | "check" | "heart";
export type GlyphKind = "bang" | "check" | "heart";

export interface GlyphFaceAnchor {
cx: number;
Expand Down Expand Up @@ -43,12 +43,11 @@ export const GLYPH_FACE_FADE = 0.3;
export const GLYPH_FIELD_SMOOTH = { radius: 4, passes: 3 } as const;
export const GLYPH_GROWTH_STEP = 3;
export const GLYPH_DOT_POP = { from: 0.76, to: 0.98 } as const;
export const STATE_GLYPH: Partial<Record<AgentState, GlyphKind>> = { alert: "bang", "needs-you": "question", done: "check" };
export const STATE_GLYPH: Partial<Record<AgentState, GlyphKind>> = { alert: "bang", done: "check" };
export const EXPRESSION_GLYPH: Partial<Record<ExpressionName, GlyphKind>> = { smitten: "heart" };
const glyphSchedule = (rest: number, hold: number, loop: boolean): GlyphSchedule => ({ rest: tempo(rest), hold: tempo(hold), loop });

export const GLYPH_SCHEDULE: Partial<Record<AgentState, GlyphSchedule>> = {
"needs-you": glyphSchedule(8, 7, true),
done: glyphSchedule(3, 7, false),
alert: glyphSchedule(6, 6, true),
};
Expand All @@ -60,15 +59,6 @@ const f = (n: number) => {
return s === "-0.00" ? "0.00" : s;
};

function arcPoints(cx: number, cy: number, r: number, fromDeg: number, toDeg: number, steps: number): Point[] {
const out: Point[] = [];
for (let i = 0; i <= steps; i++) {
const a = ((fromDeg + ((toDeg - fromDeg) * i) / steps) * Math.PI) / 180;
out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
}
return out;
}

function linePoints(from: Point, to: Point, steps: number, skipFirst = true): Point[] {
const out: Point[] = [];
for (let i = skipFirst ? 1 : 0; i <= steps; i++) {
Expand Down Expand Up @@ -186,51 +176,8 @@ export function dotBelowStroke(end: Point, endWidth: number): Circle {
return { cx: end[0], cy: end[1] + half + GLYPH_DOT_GAP + r, r };
}

function cubicPoints(p0: Point, p1: Point, p2: Point, p3: Point, steps: number, skipFirst: boolean): Point[] {
const out: Point[] = [];
for (let i = skipFirst ? 1 : 0; i <= steps; i++) {
const t = i / steps;
const u = 1 - t;
out.push([u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]]);
}
return out;
}

export const QUESTION_HOOK = { cx: 51, cy: 31.5, r: 20.5, from: 177, to: 405, steps: 84 } as const;
export const QUESTION_TAIL = { end: [50, 62] as Point, lead: 6, settle: 7, steps: 28 } as const;

const bump = (t: number, at: number, spread: number) => Math.exp(-(((t - at) / spread) ** 2));

export const QUESTION_GROWTH = { trim: 0.42, start: 0.5 } as const;

const questionWidth = (t: number) => 12.6 + 3.4 * bump(t, 0.42, 0.2) + 1.2 * bump(t, 0, 0.07);

function questionCentre(): Point[] {
const { cx, cy, r, from, to, steps } = QUESTION_HOOK;
const hook = arcPoints(cx, cy, r, from, to, steps);
const end = hook[hook.length - 1];
const angle = (to * Math.PI) / 180;
const lead: Point = [end[0] - Math.sin(angle) * QUESTION_TAIL.lead, end[1] + Math.cos(angle) * QUESTION_TAIL.lead];
const settle: Point = [QUESTION_TAIL.end[0], QUESTION_TAIL.end[1] - QUESTION_TAIL.settle];
return [...hook, ...cubicPoints(end, lead, settle, QUESTION_TAIL.end, QUESTION_TAIL.steps, true)];
}

function arcFractions(centre: readonly Point[]): number[] {
const lengths = [0];
for (let i = 1; i < centre.length; i++) lengths.push(lengths[i - 1] + Math.hypot(centre[i][0] - centre[i - 1][0], centre[i][1] - centre[i - 1][1]));
const total = lengths[lengths.length - 1] || 1;
return lengths.map((l) => l / total);
}

function questionOutline(): Polygon {
return strokeOutline(questionCentre(), questionWidth);
}

function questionGrowth(): GlyphGrowth {
const centre = questionCentre();
return { centre, halfWidths: arcFractions(centre).map((t) => questionWidth(t) / 2), trim: QUESTION_GROWTH.trim, start: QUESTION_GROWTH.start };
}

function checkOutline(): Polygon {
const a: Point = [19, 52];
const corner: Point = [41, 74];
Expand All @@ -242,7 +189,6 @@ function checkOutline(): Polygon {

export const GLYPHS: Record<GlyphKind, GlyphSpec> = {
bang: { outline: bangOutline(), dot: dotBelowStroke(BANG_STEM.to, bangWidth(1)), face: null },
question: { outline: questionOutline(), dot: dotBelowStroke(QUESTION_TAIL.end, questionWidth(1)), face: null, growth: questionGrowth() },
check: { outline: checkOutline(), dot: null, face: null },
heart: { outline: heartOutline(), dot: null, face: { cx: 50, cy: 47, scale: 0.92 } },
};
Expand Down
Loading