diff --git a/.changeset/remove-question-glyph.md b/.changeset/remove-question-glyph.md
new file mode 100644
index 0000000..84d274f
--- /dev/null
+++ b/.changeset/remove-question-glyph.md
@@ -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.
diff --git a/README.md b/README.md
index 4e5e8ed..bd87e90 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://bundlejs.com/?q=agentfaces)
[](./LICENSE)
-

+
**Give your AI agent a face: one component that shows thinking, working, needs you and done at a glance, on web and React Native.**
@@ -24,7 +24,7 @@ Expressive faces for AI agents. Show what an agent is doing (thinking, working,

- **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.
@@ -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
diff --git a/assets/demo.gif b/assets/demo.gif
index 5ce2176..42cea05 100644
Binary files a/assets/demo.gif and b/assets/demo.gif differ
diff --git a/llms-full.txt b/llms-full.txt
index d68caf8..80c2df3 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -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.
diff --git a/src/core/glyph.test.ts b/src/core/glyph.test.ts
index b51e798..9497a5e 100644
--- a/src/core/glyph.test.ts
+++ b/src/core/glyph.test.ts
@@ -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";
@@ -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);
@@ -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", () => {
@@ -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);
diff --git a/src/core/glyph.ts b/src/core/glyph.ts
index 8e918d5..d4bdbb2 100644
--- a/src/core/glyph.ts
+++ b/src/core/glyph.ts
@@ -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;
@@ -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> = { alert: "bang", "needs-you": "question", done: "check" };
+export const STATE_GLYPH: Partial> = { alert: "bang", done: "check" };
export const EXPRESSION_GLYPH: Partial> = { smitten: "heart" };
const glyphSchedule = (rest: number, hold: number, loop: boolean): GlyphSchedule => ({ rest: tempo(rest), hold: tempo(hold), loop });
export const GLYPH_SCHEDULE: Partial> = {
- "needs-you": glyphSchedule(8, 7, true),
done: glyphSchedule(3, 7, false),
alert: glyphSchedule(6, 6, true),
};
@@ -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++) {
@@ -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];
@@ -242,7 +189,6 @@ function checkOutline(): Polygon {
export const GLYPHS: Record = {
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 } },
};