agent-robot-avatar 0.2.1 → 0.3.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/index.d.ts CHANGED
@@ -38,8 +38,38 @@ export type AgentRobotAvatarState =
38
38
  | 'inspect'
39
39
  | 'failure';
40
40
 
41
+ export type AgentRobotAvatarCanonicalAction =
42
+ | 'idle'
43
+ | 'bored'
44
+ | 'waiting'
45
+ | 'input'
46
+ | 'send'
47
+ | 'success'
48
+ | 'failure'
49
+ | 'warning'
50
+ | 'inspect'
51
+ | 'angry'
52
+ | 'blocked'
53
+ | 'error'
54
+ | 'surprise'
55
+ | 'sleep'
56
+ | 'wake'
57
+ | 'reaction';
58
+
59
+ export type AgentRobotAvatarActionPhase = 'start' | 'end' | 'cancel';
60
+ export type AgentRobotAvatarActionSource = 'api' | 'interaction' | 'automatic';
61
+ export type AgentRobotAvatarWakeOn = 'activity' | 'interaction' | 'manual';
62
+ export type AgentRobotAvatarMotion = 'auto' | 'reduce' | 'full';
63
+
64
+ export interface AgentRobotAvatarActionStateDetail {
65
+ action: AgentRobotAvatarCanonicalAction;
66
+ phase: AgentRobotAvatarActionPhase;
67
+ source: AgentRobotAvatarActionSource;
68
+ }
69
+
41
70
  export interface AgentRobotAvatarEventMap extends HTMLElementEventMap {
42
71
  'face-state': CustomEvent<{ state: AgentRobotAvatarState }>;
72
+ 'action-state': CustomEvent<AgentRobotAvatarActionStateDetail>;
43
73
  'head-roundness-change': CustomEvent<{ value: number; version: string }>;
44
74
  }
45
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-robot-avatar",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight expressive robot avatar Web Component for AI agents and interactive applications.",
5
5
  "type": "module",
6
6
  "main": "./agent-robot-avatar.js",
@@ -1,7 +1,7 @@
1
1
  import AgentRobotAvatar, { registerAvatarExtension } from './agent-robot-avatar-extension-host.js';
2
+ import { DEFAULT_HEAD_HEIGHT_SCALE, flattenHeadPoints } from './agent-robot-avatar-geometry.js';
2
3
 
3
4
  const SVG_NS = 'http://www.w3.org/2000/svg';
4
- const HEAD_HEIGHT_SCALE = 0.9;
5
5
  const ANTENNA_DEFAULTS = Object.freeze({
6
6
  x: 120,
7
7
  y: 12,
@@ -35,40 +35,10 @@ function antennaHome() {
35
35
  };
36
36
  }
37
37
 
38
- function smooth01(t) {
39
- t = Math.max(0, Math.min(1, t));
40
- return t * t * (3 - 2 * t);
41
- }
42
-
43
38
  function flattenHead(instance) {
44
39
  if (instance._headFlattenedR32 || !instance._headShape || !instance._baseHeadPoints?.length) return;
45
40
 
46
- const source = instance._baseHeadPoints.map(point => ({ x: point.x, y: point.y }));
47
- const minY = Math.min(...source.map(point => point.y));
48
- const maxY = Math.max(...source.map(point => point.y));
49
- const height = maxY - minY;
50
- const centerY = (minY + maxY) / 2;
51
- const inset = height * (1 - HEAD_HEIGHT_SCALE) / 2;
52
-
53
- const upperLockY = minY + height * 0.40;
54
- const lowerLockY = maxY - height * 0.40;
55
-
56
- const flattened = source.map(point => {
57
- let shiftY = 0;
58
- if (point.y <= upperLockY) {
59
- shiftY = inset;
60
- } else if (point.y < centerY) {
61
- const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
62
- shiftY = inset * smooth01(t);
63
- } else if (point.y >= lowerLockY) {
64
- shiftY = -inset;
65
- } else if (point.y > centerY) {
66
- const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
67
- shiftY = -inset * smooth01(t);
68
- }
69
- return { x: point.x, y: point.y + shiftY };
70
- });
71
-
41
+ const flattened = flattenHeadPoints(instance._baseHeadPoints, DEFAULT_HEAD_HEIGHT_SCALE);
72
42
  const path = typeof instance._pointsToPath === 'function'
73
43
  ? instance._pointsToPath(flattened)
74
44
  : `M ${flattened.map((point, index) => `${index ? 'L ' : ''}${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(' ')} Z`;
@@ -135,24 +105,33 @@ function updateAntenna(instance, now) {
135
105
  const dot = instance._antennaDot;
136
106
  const target = antennaTarget(instance);
137
107
 
138
- if (!spring.initialized) {
108
+ if (instance._isReducedMotion?.()) {
139
109
  spring.x = target.x;
140
110
  spring.y = target.y;
111
+ spring.vx = 0;
112
+ spring.vy = 0;
141
113
  spring.last = now;
142
114
  spring.initialized = true;
143
- }
115
+ } else {
116
+ if (!spring.initialized) {
117
+ spring.x = target.x;
118
+ spring.y = target.y;
119
+ spring.last = now;
120
+ spring.initialized = true;
121
+ }
122
+
123
+ const dt = Math.max(0.001, Math.min(0.05, (now - spring.last) / 1000 || 0.033));
124
+ spring.last = now;
144
125
 
145
- const dt = Math.max(0.001, Math.min(0.05, (now - spring.last) / 1000 || 0.033));
146
- spring.last = now;
147
-
148
- const stiffness = configNumber('stiffness', ANTENNA_DEFAULTS.stiffness, 5, 300);
149
- const damping = configNumber('damping', ANTENNA_DEFAULTS.damping, 0.5, 40);
150
- const ax = (target.x - spring.x) * stiffness - spring.vx * damping;
151
- const ay = (target.y - spring.y) * stiffness - spring.vy * damping;
152
- spring.vx += ax * dt;
153
- spring.vy += ay * dt;
154
- spring.x += spring.vx * dt;
155
- spring.y += spring.vy * dt;
126
+ const stiffness = configNumber('stiffness', ANTENNA_DEFAULTS.stiffness, 5, 300);
127
+ const damping = configNumber('damping', ANTENNA_DEFAULTS.damping, 0.5, 40);
128
+ const ax = (target.x - spring.x) * stiffness - spring.vx * damping;
129
+ const ay = (target.y - spring.y) * stiffness - spring.vy * damping;
130
+ spring.vx += ax * dt;
131
+ spring.vy += ay * dt;
132
+ spring.x += spring.vx * dt;
133
+ spring.y += spring.vy * dt;
134
+ }
156
135
 
157
136
  dot.setAttribute('cx', spring.x.toFixed(2));
158
137
  dot.setAttribute('cy', spring.y.toFixed(2));
@@ -0,0 +1,40 @@
1
+ const DEFAULT_HEAD_HEIGHT_SCALE = 0.9;
2
+
3
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
4
+ const smooth01 = value => {
5
+ const t = clamp(value, 0, 1);
6
+ return t * t * (3 - 2 * t);
7
+ };
8
+
9
+ function flattenHeadPoints(points, heightScale = DEFAULT_HEAD_HEIGHT_SCALE) {
10
+ if (!points?.length) return [];
11
+ const source = points.map(point => ({ x: point.x, y: point.y }));
12
+ const minY = Math.min(...source.map(point => point.y));
13
+ const maxY = Math.max(...source.map(point => point.y));
14
+ const height = maxY - minY;
15
+ if (!(height > 0)) return source;
16
+
17
+ const scale = clamp(Number(heightScale), 0.1, 1);
18
+ const centerY = (minY + maxY) / 2;
19
+ const inset = height * (1 - scale) / 2;
20
+ const upperLockY = minY + height * 0.40;
21
+ const lowerLockY = maxY - height * 0.40;
22
+
23
+ return source.map(point => {
24
+ let shiftY = 0;
25
+ if (point.y <= upperLockY) {
26
+ shiftY = inset;
27
+ } else if (point.y < centerY) {
28
+ const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
29
+ shiftY = inset * smooth01(t);
30
+ } else if (point.y >= lowerLockY) {
31
+ shiftY = -inset;
32
+ } else if (point.y > centerY) {
33
+ const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
34
+ shiftY = -inset * smooth01(t);
35
+ }
36
+ return { x: point.x, y: point.y + shiftY };
37
+ });
38
+ }
39
+
40
+ export { DEFAULT_HEAD_HEIGHT_SCALE, flattenHeadPoints };
@@ -1,47 +1,14 @@
1
1
  import AgentRobotAvatar from './agent-robot-avatar-extension-host.js';
2
+ import { flattenHeadPoints } from './agent-robot-avatar-geometry.js';
2
3
  import { VERSION } from './agent-robot-avatar-version.js';
3
4
 
4
5
  const proto = AgentRobotAvatar.prototype;
5
- const HEAD_HEIGHT_SCALE = 0.9;
6
6
  const DEFAULT_ROUNDNESS = 50;
7
7
 
8
8
  function clamp(value, min, max) {
9
9
  return Math.max(min, Math.min(max, value));
10
10
  }
11
11
 
12
- function smooth01(t) {
13
- t = clamp(t, 0, 1);
14
- return t * t * (3 - 2 * t);
15
- }
16
-
17
- function flattenPoints(points) {
18
- if (!points?.length) return [];
19
- const source = points.map(point => ({ x: point.x, y: point.y }));
20
- const minY = Math.min(...source.map(point => point.y));
21
- const maxY = Math.max(...source.map(point => point.y));
22
- const height = maxY - minY;
23
- const centerY = (minY + maxY) / 2;
24
- const inset = height * (1 - HEAD_HEIGHT_SCALE) / 2;
25
- const upperLockY = minY + height * 0.40;
26
- const lowerLockY = maxY - height * 0.40;
27
-
28
- return source.map(point => {
29
- let shiftY = 0;
30
- if (point.y <= upperLockY) {
31
- shiftY = inset;
32
- } else if (point.y < centerY) {
33
- const t = (centerY - point.y) / Math.max(0.001, centerY - upperLockY);
34
- shiftY = inset * smooth01(t);
35
- } else if (point.y >= lowerLockY) {
36
- shiftY = -inset;
37
- } else if (point.y > centerY) {
38
- const t = (point.y - centerY) / Math.max(0.001, lowerLockY - centerY);
39
- shiftY = -inset * smooth01(t);
40
- }
41
- return { x: point.x, y: point.y + shiftY };
42
- });
43
- }
44
-
45
12
  function ensureDefaultGeometry(instance) {
46
13
  if (!instance?._headShape || !instance._baseHeadPoints?.length) return false;
47
14
  if (instance._headRoundnessDefaultPoints?.length) return true;
@@ -49,7 +16,7 @@ function ensureDefaultGeometry(instance) {
49
16
  let points = instance._baseHeadPoints.map(point => ({ x: point.x, y: point.y }));
50
17
 
51
18
  if (!instance._headFlattenedR32) {
52
- points = flattenPoints(points);
19
+ points = flattenHeadPoints(points);
53
20
  instance._headFlattenedR32 = true;
54
21
  instance._baseHeadPoints = points.map(point => ({ ...point }));
55
22
  instance._baseHeadPathD = instance._pointsToPath(points);
@@ -31,46 +31,54 @@ const INSPECT_DEFAULTS = Object.freeze({
31
31
  open: 260,
32
32
  });
33
33
 
34
+ const INSPECT_BOUNDS = Object.freeze({
35
+ aperture: [6, 40],
36
+ scanOffset: [0, 24],
37
+ prepPause: [0, 2400],
38
+ close: [40, 2400],
39
+ holdClosed: [0, 2400],
40
+ down1: [40, 2400],
41
+ holdDown1: [0, 2400],
42
+ up1: [40, 2400],
43
+ holdUp1: [0, 2400],
44
+ center1: [40, 2400],
45
+ holdCenter: [0, 2400],
46
+ down2: [40, 2400],
47
+ holdDown2: [0, 2400],
48
+ up2: [40, 2400],
49
+ holdUp2: [0, 2400],
50
+ center2: [40, 2400],
51
+ holdAfter: [0, 2400],
52
+ open: [40, 2400],
53
+ });
54
+
34
55
  const runtimeWindow = typeof window !== 'undefined' ? window : null;
35
56
  const inspectConfig = (runtimeWindow?.AgentRobotAvatarInspectConfig && typeof runtimeWindow.AgentRobotAvatarInspectConfig === 'object')
36
57
  ? runtimeWindow.AgentRobotAvatarInspectConfig
37
58
  : {};
38
- for (const [key, value] of Object.entries(INSPECT_DEFAULTS)) {
39
- inspectConfig[key] = value;
59
+
60
+ function normalizeConfigValue(key, value) {
61
+ const fallback = INSPECT_DEFAULTS[key];
62
+ const numeric = Number(value);
63
+ if (!Number.isFinite(numeric)) return fallback;
64
+ const [min, max] = INSPECT_BOUNDS[key];
65
+ return Math.max(min, Math.min(max, numeric));
66
+ }
67
+
68
+ for (const key of Object.keys(INSPECT_DEFAULTS)) {
69
+ inspectConfig[key] = normalizeConfigValue(key, inspectConfig[key]);
40
70
  }
41
71
  if (runtimeWindow) {
42
72
  runtimeWindow.AgentRobotAvatarInspectConfig = inspectConfig;
43
73
  runtimeWindow.AgentRobotAvatarInspectDefaults = INSPECT_DEFAULTS;
44
74
  }
45
75
 
46
- function configNumber(key, fallback, min, max) {
47
- const value = Number(inspectConfig[key]);
48
- if (!Number.isFinite(value)) return fallback;
49
- return Math.max(min, Math.min(max, value));
50
- }
51
-
52
76
  function currentInspectConfig() {
53
- const duration = key => configNumber(key, INSPECT_DEFAULTS[key], 0, 2400);
54
- return {
55
- aperture: configNumber('aperture', INSPECT_DEFAULTS.aperture, 6, 40),
56
- scanOffset: configNumber('scanOffset', INSPECT_DEFAULTS.scanOffset, 0, 24),
57
- prepPause: duration('prepPause'),
58
- close: Math.max(40, duration('close')),
59
- holdClosed: duration('holdClosed'),
60
- down1: Math.max(40, duration('down1')),
61
- holdDown1: duration('holdDown1'),
62
- up1: Math.max(40, duration('up1')),
63
- holdUp1: duration('holdUp1'),
64
- center1: Math.max(40, duration('center1')),
65
- holdCenter: duration('holdCenter'),
66
- down2: Math.max(40, duration('down2')),
67
- holdDown2: duration('holdDown2'),
68
- up2: Math.max(40, duration('up2')),
69
- holdUp2: duration('holdUp2'),
70
- center2: Math.max(40, duration('center2')),
71
- holdAfter: duration('holdAfter'),
72
- open: Math.max(40, duration('open')),
73
- };
77
+ const config = {};
78
+ for (const key of Object.keys(INSPECT_DEFAULTS)) {
79
+ config[key] = normalizeConfigValue(key, inspectConfig[key]);
80
+ }
81
+ return config;
74
82
  }
75
83
 
76
84
  function dispatchInspectState(instance, state) {
@@ -0,0 +1,178 @@
1
+ import AgentRobotAvatar from './agent-robot-avatar-extension-host.js';
2
+
3
+ const proto = AgentRobotAvatar.prototype;
4
+ const baseUpdateDragJelly = proto._updateDragJelly;
5
+ const baseResumeFrames = proto._resumeFrames;
6
+ const baseCanPauseFrames = proto._canPauseFrames;
7
+ const baseDraw = proto._draw;
8
+ const baseIsReducedMotion = proto._isReducedMotion;
9
+
10
+ let currentEnvironmentEvent = null;
11
+ let clearEnvironmentTimer = 0;
12
+
13
+ function rememberEnvironmentEvent(event) {
14
+ currentEnvironmentEvent = event;
15
+ if (clearEnvironmentTimer) clearTimeout(clearEnvironmentTimer);
16
+ clearEnvironmentTimer = setTimeout(() => {
17
+ currentEnvironmentEvent = null;
18
+ clearEnvironmentTimer = 0;
19
+ }, 0);
20
+ }
21
+
22
+ if (typeof document !== 'undefined') {
23
+ document.addEventListener('pointerdown', rememberEnvironmentEvent, true);
24
+ document.addEventListener('keydown', rememberEnvironmentEvent, true);
25
+ }
26
+
27
+ function wakePolicy(instance) {
28
+ const value = String(instance.getAttribute('wake-on') || 'activity').trim().toLowerCase();
29
+ return value === 'manual' || value === 'interaction' ? value : 'activity';
30
+ }
31
+
32
+ function eventTargetsAvatar(event, instance) {
33
+ const path = event?.composedPath?.();
34
+ if (Array.isArray(path)) return path.includes(instance);
35
+ return event?.target === instance || instance.contains?.(event?.target);
36
+ }
37
+
38
+ // Keep the public noteActivity(wake?) contract, but distinguish calls made by
39
+ // the shared environment listeners from explicit API calls. Environment input
40
+ // follows wake-on; an explicit noteActivity() remains an intentional wake.
41
+ proto.noteActivity = function(wake = true) {
42
+ this._lastActivity = performance.now();
43
+ this._resumeFrames?.();
44
+ if (!wake || (!this._sleeping && this._state !== 'sleep')) return;
45
+
46
+ const event = currentEnvironmentEvent;
47
+ if (!event) {
48
+ this.wake();
49
+ return;
50
+ }
51
+
52
+ const policy = wakePolicy(this);
53
+ const shouldWake = policy === 'activity' || (policy === 'interaction' && eventTargetsAvatar(event, this));
54
+ if (!shouldWake) return;
55
+
56
+ const previousSource = this._runtimeActionSource;
57
+ this._runtimeActionSource = 'automatic';
58
+ try {
59
+ this.wake();
60
+ } finally {
61
+ this._runtimeActionSource = previousSource;
62
+ }
63
+ };
64
+
65
+ function dragAtRest(drag) {
66
+ if (!drag || drag.active || drag.returning || drag.pendingReaction) return false;
67
+ return Math.abs(drag.x) < 0.0001 && Math.abs(drag.y) < 0.0001 &&
68
+ Math.abs(drag.vx) < 0.0001 && Math.abs(drag.vy) < 0.0001 &&
69
+ Math.abs(drag.stretch) < 0.0001 && Math.abs(drag.shear) < 0.0001 &&
70
+ Math.abs(drag.pullX) < 0.0001 && Math.abs(drag.pullY) < 0.0001;
71
+ }
72
+
73
+ // Core's original jelly renderer writes an identity transform every frame even
74
+ // after reset. Clear that identity transform once the interaction is fully at
75
+ // rest so reset/disconnect leave no visual residue or unnecessary style churn.
76
+ proto._updateDragJelly = function(dt) {
77
+ const result = baseUpdateDragJelly.call(this, dt);
78
+ if (dragAtRest(this._dragJelly)) {
79
+ if (this._dragMotion) {
80
+ this._dragMotion.style.transform = '';
81
+ this._dragMotion.style.transformOrigin = '';
82
+ }
83
+ if (this._headShape && this._baseHeadPathD) {
84
+ this._headShape.setAttribute('d', this._baseHeadPathD);
85
+ }
86
+ }
87
+ return result;
88
+ };
89
+
90
+ function cancelContinuousMotion(instance) {
91
+ const animations = instance._headMotion?.getAnimations?.() || [];
92
+ for (const animation of animations) animation.cancel();
93
+ if (instance._headMotion) instance._headMotion.style.transform = '';
94
+ instance._blinkAnim = null;
95
+ instance._blink = 1;
96
+ instance._boredRoutine = null;
97
+ instance._boredLookSpeed = null;
98
+ if (instance._wanderTarget) instance._wanderTarget.x = instance._wanderTarget.y = 0;
99
+ if (instance._look) instance._look.x = instance._look.y = 0;
100
+ }
101
+
102
+ function requestedMotion(instance) {
103
+ const value = String(instance.getAttribute('motion') || 'auto').trim().toLowerCase();
104
+ return value === 'reduce' || value === 'full' ? value : 'auto';
105
+ }
106
+
107
+ function effectiveReducedMotion(instance) {
108
+ const requested = requestedMotion(instance);
109
+ if (requested === 'reduce') return true;
110
+ if (requested === 'full') return false;
111
+
112
+ // A fresh MediaQueryList avoids engine-specific delays where an older
113
+ // MediaQueryList object's matches value lags behind a runtime preference
114
+ // update. Fall back to the coordinator's original resolver when unavailable.
115
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
116
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
117
+ }
118
+ return baseIsReducedMotion.call(instance);
119
+ }
120
+
121
+ function syncMotionPreference(instance) {
122
+ const reduced = effectiveReducedMotion(instance);
123
+ if (instance._runtimeLastReducedMotion === reduced) return reduced;
124
+ instance._runtimeLastReducedMotion = reduced;
125
+ instance._runtimeVisualDirty = true;
126
+ if (reduced) cancelContinuousMotion(instance);
127
+ return reduced;
128
+ }
129
+
130
+ // Some engines deliver matchMedia change events later than the next animation
131
+ // frame. Also sample the effective preference while frames are active so a
132
+ // runtime switch cannot leave an old Web Animation running until that event.
133
+ proto._isReducedMotion = function() {
134
+ return syncMotionPreference(this);
135
+ };
136
+
137
+ // Reduced motion should render each newly reached static state once, then stop
138
+ // scheduling frames. Extension effects historically create some Web Animations
139
+ // directly instead of routing through _animateHead; cancel those in a microtask
140
+ // after an effect becomes active so ordinary-mode animation code stays intact.
141
+ for (const property of ['_waitingFx', '_inspectFx', '_failureFx', '_warningFx', '_systemErrorShake']) {
142
+ const slot = Symbol(property);
143
+ Object.defineProperty(proto, property, {
144
+ configurable: true,
145
+ get() { return this[slot]; },
146
+ set(value) {
147
+ this[slot] = value;
148
+ this._runtimeVisualDirty = true;
149
+ if (this.isConnected && this._isReducedMotion?.()) {
150
+ this._resumeFrames?.();
151
+ queueMicrotask(() => {
152
+ if (this.isConnected && this._isReducedMotion?.()) cancelContinuousMotion(this);
153
+ });
154
+ }
155
+ },
156
+ });
157
+ }
158
+
159
+ proto._resumeFrames = function() {
160
+ if (this._isReducedMotion?.()) this._runtimeVisualDirty = true;
161
+ return baseResumeFrames.call(this);
162
+ };
163
+
164
+ proto._draw = function(now) {
165
+ syncMotionPreference(this);
166
+ const result = baseDraw.call(this, now);
167
+ this._runtimeVisualDirty = false;
168
+ return result;
169
+ };
170
+
171
+ proto._canPauseFrames = function(now) {
172
+ syncMotionPreference(this);
173
+ if (this._isReducedMotion?.() && this._runtimeVisualDirty) return false;
174
+ return baseCanPauseFrames.call(this, now);
175
+ };
176
+
177
+ export { AgentRobotAvatar };
178
+ export default AgentRobotAvatar;