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 @@ [![bundle size](https://img.shields.io/bundlejs/size/agentfaces)](https://bundlejs.com/?q=agentfaces) [![license](https://img.shields.io/npm/l/agentfaces.svg)](./LICENSE) -

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

+

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

**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, ![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. @@ -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 } }, };