@heyramzi/ui 6.0.1 → 6.1.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/COMPONENTS.md CHANGED
@@ -137,5 +137,5 @@ The style layer lives in [`src/tokens.css`](./src/tokens.css) and the motion CSS
137
137
  ---
138
138
 
139
139
  Skipped on purpose: brand assets (logos, marks, brand renderer, language flags,
140
- locale switch), the bot avatars (they live in `@heyramzi/agents`), kanban and status
141
- (they live in `@heyramzi/board`).
140
+ locale switch), kanban and status (they live in `@heyramzi/board`). The bot avatars
141
+ are in `src/agents`, imported from `@heyramzi/ui/agents`.
package/README.md CHANGED
@@ -18,6 +18,9 @@ only a convenience.
18
18
  - `src/ai` chat and AI surfaces: conversation, composer, prompt input, response, reasoning, confirmation and loaders.
19
19
  - `src/charts` SVG charts: line, area, bar, donut, ring, gauge, sparkline,
20
20
  time series, and stat cards.
21
+ - `src/agents` bot avatars and a cast grid, imported from `@heyramzi/ui/agents`:
22
+ `AgentAvatar` draws an eyes-only face from a name, role, shape and colour, and
23
+ `AgentCast` lays a list of agents out as cards you can pick from.
21
24
 
22
25
  See [COMPONENTS.md](./COMPONENTS.md) for the full list, one line each.
23
26
 
@@ -119,7 +122,9 @@ there, use its page for props and examples:
119
122
  Inspired by [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte).
120
123
  The reveal, marquee and beam effects are inspired by
121
124
  [motion-primitives](https://github.com/ibelick/motion-primitives), and the
122
- motion vocabulary by [transitions-dev](https://transitions.dev).
125
+ motion vocabulary by [transitions-dev](https://transitions.dev). The agent avatars
126
+ vendor [bot-avatars](https://github.com/jakubantalik/bot-avatars); its licence is in
127
+ `src/agents/vendor/LICENSE`.
123
128
 
124
129
  ## License
125
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "description": "Svelte 5 UI primitives: effects, animations, and shared component CSS.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -153,7 +153,13 @@
153
153
  "svelte": "./src/ai/*/index.ts",
154
154
  "types": "./src/ai/*/index.ts",
155
155
  "default": "./src/ai/*/index.ts"
156
- }
156
+ },
157
+ "./agents": {
158
+ "svelte": "./src/agents/index.ts",
159
+ "types": "./src/agents/index.ts",
160
+ "default": "./src/agents/index.ts"
161
+ },
162
+ "./agents/demo.svelte": "./src/agents/demo.svelte"
157
163
  },
158
164
  "dependencies": {
159
165
  "@heyramzi/utils": "^1.3.3",
@@ -0,0 +1,639 @@
1
+ <script module lang="ts">
2
+ /* The batching shown-gate and the path caches are shared by every avatar
3
+ on the page, so a row of them comes in together. Engine: ./vendor. */
4
+ import { bakesPending as bakesPendingGlobal } from './vendor/plastic';
5
+
6
+ const SHOW_WITHIN = 4000;
7
+ const waiters = new Map<object, { ready: () => boolean; show: () => void }>();
8
+
9
+ function showReady() {
10
+ if (bakesPendingGlobal()) return;
11
+ for (const w of waiters.values()) if (!w.ready()) return;
12
+ const all = [...waiters.values()];
13
+ waiters.clear();
14
+ for (const w of all) w.show();
15
+ }
16
+
17
+ const pathCache = new Map<string, Path2D>();
18
+ function bodyPath(d: string): Path2D {
19
+ let p = pathCache.get(d);
20
+ if (!p) {
21
+ p = new Path2D(d);
22
+ pathCache.set(d, p);
23
+ }
24
+ return p;
25
+ }
26
+
27
+ const partsCache = new Map<string, Path2D[]>();
28
+ function partPaths(d: string): Path2D[] {
29
+ let p = partsCache.get(d);
30
+ if (!p) {
31
+ p = d
32
+ .split(/(?=M)/)
33
+ .filter((s) => s.trim())
34
+ .map((s) => new Path2D(s));
35
+ partsCache.set(d, p);
36
+ }
37
+ return p;
38
+ }
39
+
40
+ let uidSeq = 0;
41
+ function hashSeed(id: string): number {
42
+ let h = 2166136261;
43
+ for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619);
44
+ return ((h >>> 0) % 1000) / 1000;
45
+ }
46
+
47
+ export const reducedMotion = (): boolean =>
48
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
49
+ </script>
50
+
51
+ <script lang="ts">
52
+ import { onMount } from 'svelte';
53
+ import type {
54
+ BotAvatarShading,
55
+ BotAvatarState,
56
+ BotAvatarType,
57
+ } from './vendor/types';
58
+ import type { AgentAvatarProps } from './types';
59
+ import { botAvatarPresets, stateLabels } from './vendor/presets';
60
+ import { SHAPE_PATHS, SHAPE_PARTS } from './vendor/shapes';
61
+ import { autoInk, richer, shade } from './vendor/color';
62
+ import { Sim, restPose } from './vendor/engine';
63
+ import { draw, LIGHT_DEFAULTS, OVERSCAN, RISE, type DrawConfig } from './vendor/draw';
64
+ import {
65
+ bakesPending,
66
+ hurry,
67
+ onLanded,
68
+ stockLights,
69
+ takeMissed,
70
+ warmPlastic,
71
+ type FurStyle,
72
+ } from './vendor/plastic';
73
+ import { subscribe, pointer } from './vendor/ticker';
74
+
75
+ type Props = AgentAvatarProps;
76
+
77
+ let {
78
+ name,
79
+ role,
80
+ shape = 'clover',
81
+ path,
82
+ face = 'eyes',
83
+ state: botState = 'default',
84
+ size,
85
+ color,
86
+ ink = '#1E1A33',
87
+ brightness: brightnessProp,
88
+ saturation: saturationProp,
89
+ speed = 1,
90
+ paused = false,
91
+ seed,
92
+ shading = 'fabric',
93
+ hat = 'none',
94
+ glasses = 'none',
95
+ headphones = false,
96
+ bowTie = false,
97
+ accessoryColor = '#27272b',
98
+ shadow,
99
+ highlight,
100
+ depth = 0.65,
101
+ roundness = 1,
102
+ furLength = 1,
103
+ furDensity = 1.6,
104
+ furFuzz = 0.9,
105
+ furClumps = 0.4,
106
+ furCurl = 0.7,
107
+ furGravity = 0.9,
108
+ light,
109
+ rim,
110
+ spread,
111
+ backLight,
112
+ lightFront,
113
+ shine,
114
+ sheen,
115
+ backSoftness,
116
+ pose: heldPose,
117
+ interactive = true,
118
+ turn = 1,
119
+ theme = 'auto',
120
+ whirl = 0,
121
+ whirlSize = 1,
122
+ whirlWidth = 1,
123
+ whirlLength = 1,
124
+ whirlTilt = 1,
125
+ jumpHeight = 26,
126
+ jumpTime = 0.68,
127
+ jumpStretch = 1,
128
+ jumpSpin = 1,
129
+ jumpLean = 6,
130
+ jumpEvery = 8,
131
+ jumpLand = 0,
132
+ jumpSquash = 1.15,
133
+ jumpSquashTime = 0.37,
134
+ jumpSquashEase = 'pulse',
135
+ jumpGroundTime = 0.11,
136
+ jumpGroundEase = 'pulse',
137
+ jumpRiseTime = 0.33,
138
+ jumpRiseEase = 'pulse',
139
+ jumpClickSquashTime = 0.24,
140
+ class: className,
141
+ style,
142
+ 'aria-label': ariaLabel,
143
+ onclick: userClick,
144
+ ...rest
145
+ }: Props = $props();
146
+
147
+ const clamp = (v: number, lo: number, hi: number) =>
148
+ Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 1));
149
+ const q = (v: number, lo: number, hi: number) => Math.round(clamp(v, lo, hi) * 20) / 20;
150
+ const clampOpt = (v: number | undefined, d: number, lo: number, hi: number) =>
151
+ clamp(v ?? d, lo, hi);
152
+ const shapePath = (t: BotAvatarType) => SHAPE_PATHS[t] ?? SHAPE_PATHS.clover;
153
+ const deviceDpr = () =>
154
+ Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
155
+
156
+ const uid = `ba-${++uidSeq}`;
157
+
158
+ /* The avatar draws at 75% of its slot so an idle jump has room in the box
159
+ and never clips. A numeric `size` overrides the measurement. */
160
+ const JUMP_CLEARANCE = 0.75;
161
+ let hostEl = $state<HTMLElement | null>(null);
162
+ let hostWidth = $state(0);
163
+ const drawnSize = $derived(
164
+ typeof size === 'number' ? size : hostWidth > 0 ? hostWidth * JUMP_CLEARANCE : 0,
165
+ );
166
+
167
+ const type = $derived(shape);
168
+ const preset = $derived(botAvatarPresets[type] ?? botAvatarPresets.clover);
169
+ const faceKind = $derived(face ?? preset.face);
170
+ const picked = $derived(color ?? preset.color);
171
+ const brightness = $derived(brightnessProp ?? (preset.brightness ?? 1));
172
+ const saturation = $derived(saturationProp ?? (preset.saturation ?? 1.5));
173
+ const vivid = $derived(clamp(saturation, 1.5, 2.5) - 1.5);
174
+ const body = $derived(
175
+ richer(
176
+ brightness === 1 && saturation === 1
177
+ ? picked
178
+ : shade(
179
+ picked,
180
+ (Math.min(2, Math.max(0, brightness)) - 1) * 0.35,
181
+ (Math.min(1.5, Math.max(0, saturation)) - 1) * 0.5,
182
+ ),
183
+ vivid,
184
+ ),
185
+ );
186
+ const inkColor = $derived(ink ?? autoInk(body));
187
+ const seedValue = $derived(Math.min(1, Math.max(0, seed ?? hashSeed(uid))));
188
+ const stateKey = $derived((botState in stateLabels ? botState : 'default') as BotAvatarState);
189
+ const frozen = $derived(paused || !(speed > 0));
190
+ const shadingMode = $derived(
191
+ (shading === true ? 'crisp' : shading === false ? 'flat' : shading) as BotAvatarShading,
192
+ );
193
+ const customPath = $derived(typeof path === 'string' && path.trim() ? path.trim() : null);
194
+ const outlineKey = $derived(
195
+ customPath ? `path:${hashSeed(customPath)}:${customPath.length}` : type,
196
+ );
197
+ const lit = $derived(LIGHT_DEFAULTS[shadingMode === 'fabric' ? 'fabric' : 'other']);
198
+ const fur = $derived({
199
+ length: q(furLength, 0.3, 2.5),
200
+ density: q(furDensity, 0.3, 2),
201
+ fuzz: q(furFuzz, 0, 1),
202
+ clumps: q(furClumps, 0, 1),
203
+ curl: q(furCurl, 0, 1),
204
+ gravity: q(furGravity, 0, 1),
205
+ } satisfies FurStyle);
206
+ const material = $derived(shadingMode === 'plastic' || shadingMode === 'fabric');
207
+ const lookKey = $derived(`${shadingMode}|${outlineKey}|${Math.round(drawnSize)}`);
208
+ const interactiveNow = $derived(interactive && !heldPose);
209
+
210
+ /* Captured once, on purpose: `shown` never returns to false, so a later
211
+ shading change must not reset it. */
212
+ function beginsMaterial() {
213
+ return shading === 'plastic' || shading === 'fabric';
214
+ }
215
+
216
+ function partsFor(t: BotAvatarType) {
217
+ return !customPath && SHAPE_PARTS[t] ? partPaths(SHAPE_PARTS[t] as string) : undefined;
218
+ }
219
+
220
+ function configure(): DrawConfig {
221
+ return {
222
+ path: bodyPath(customPath ?? shapePath(type)),
223
+ face: faceKind,
224
+ faceX: preset.faceX,
225
+ faceY: preset.faceY,
226
+ faceScale: preset.faceScale,
227
+ color: body,
228
+ ink: inkColor,
229
+ shading: shadingMode,
230
+ shadow: clampOpt(shadow, lit.shadow, 0, 2),
231
+ highlight: clampOpt(highlight, lit.highlight, 0, 2),
232
+ depth: clamp(depth, 0.2, 2),
233
+ roundness: clamp(roundness, 0, 1),
234
+ fur,
235
+ vivid,
236
+ backLight,
237
+ lightFront,
238
+ shine,
239
+ sheen,
240
+ backSoftness,
241
+ light: light ?? lit.light,
242
+ rim: clampOpt(rim, lit.rim, 0, 2),
243
+ spread: clampOpt(spread, lit.spread, 0.4, 2.5),
244
+ typeKey: outlineKey,
245
+ still: frozen || reducedMotion(),
246
+ furOnIdle: true,
247
+ whirl: {
248
+ strength: clamp(whirl, 0, 2),
249
+ size: clamp(whirlSize, 0.6, 1.6),
250
+ width: clamp(whirlWidth, 0.4, 2),
251
+ length: clamp(whirlLength, 0.4, 1.6),
252
+ tilt: clamp(whirlTilt, 0.5, 1.8),
253
+ },
254
+ parts: partsFor(type),
255
+ wear: { hat, glasses, headphones, bowTie, color: accessoryColor, fabric: shadingMode === 'fabric' },
256
+ };
257
+ }
258
+
259
+ const cfg = $derived(configure());
260
+
261
+ let canvasEl = $state<HTMLCanvasElement | null>(null);
262
+ let sim: Sim | null = null;
263
+ let cssSize = 0;
264
+ let lastLook: string | null = null;
265
+
266
+ let shown = $state(!beginsMaterial());
267
+ let shownRef = !beginsMaterial();
268
+ function markShown() {
269
+ shownRef = true;
270
+ shown = true;
271
+ }
272
+
273
+ let stillPaint: (() => void) | null = null;
274
+ let waiting: (() => void) | null = null;
275
+ let watch: (() => void) | null = null;
276
+ let showPaint: (() => void) | null = null;
277
+ let ready = false;
278
+ let holding: (() => void) | null = null;
279
+ const meRef = {};
280
+
281
+ function resolveTheme(el: HTMLElement | null): 'dark' | 'light' {
282
+ if (theme !== 'auto') return theme;
283
+ const host = el?.closest('[data-theme], .dark, .light') as HTMLElement | null;
284
+ if (host) {
285
+ const v = host.getAttribute('data-theme');
286
+ if (v === 'dark' || v === 'light') return v;
287
+ if (host.classList.contains('dark')) return 'dark';
288
+ if (host.classList.contains('light')) return 'light';
289
+ }
290
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches
291
+ ? 'light'
292
+ : 'dark';
293
+ }
294
+
295
+ function layoutPx(canvas: HTMLCanvasElement) {
296
+ return canvas.clientWidth / OVERSCAN || cssSize || drawnSize || 64;
297
+ }
298
+
299
+ function poseFor(rest0: ReturnType<typeof restPose>) {
300
+ const held = heldPose;
301
+ if (held)
302
+ return { ...rest0, yaw: held.yaw ?? 0, pitch: held.pitch ?? rest0.pitch, roll: held.roll ?? rest0.roll };
303
+ return sim ? sim.pose : rest0;
304
+ }
305
+
306
+ function paint() {
307
+ const canvas = canvasEl;
308
+ const c = cfg;
309
+ if (!canvas || !c?.path) return;
310
+ const px = layoutPx(canvas);
311
+ if (!px) return;
312
+ const dpr = deviceDpr();
313
+ const want = Math.round(px * OVERSCAN * dpr);
314
+ if (canvas.width !== want || canvas.height !== want) {
315
+ canvas.width = want;
316
+ canvas.height = want;
317
+ }
318
+ cssSize = px;
319
+ const ctx = canvas.getContext('2d');
320
+ if (!ctx) return;
321
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
322
+ c.dpr = dpr;
323
+ draw(ctx, px, poseFor(restPose(stateKey)), c);
324
+ }
325
+
326
+ function paintStill() {
327
+ const canvas = canvasEl;
328
+ const c = cfg;
329
+ if (!canvas || !c?.path) return;
330
+ const px = layoutPx(canvas);
331
+ if (!px) return;
332
+ cssSize = px;
333
+ const dpr = deviceDpr();
334
+ canvas.width = canvas.height = Math.round(px * OVERSCAN * dpr);
335
+ const ctx = canvas.getContext('2d');
336
+ if (ctx) {
337
+ c.theme = resolveTheme(canvas);
338
+ c.dpr = dpr;
339
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
340
+ draw(ctx, px, restPose(stateKey), c);
341
+ }
342
+ }
343
+
344
+ function stopWaiting() {
345
+ if (waiting) waiting();
346
+ waiting = null;
347
+ }
348
+
349
+ function settle(paintNow: () => void) {
350
+ takeMissed();
351
+ paintNow();
352
+ if (!takeMissed()) stopWaiting();
353
+ else if (!waiting)
354
+ waiting = onLanded(() => {
355
+ const p = stillPaint;
356
+ if (canvasEl && p) settle(p);
357
+ else stopWaiting();
358
+ });
359
+ }
360
+
361
+ function stopWatch() {
362
+ if (watch) watch();
363
+ watch = null;
364
+ }
365
+
366
+ function showWhenBaked(paintNow: () => void) {
367
+ showPaint = paintNow;
368
+ takeMissed();
369
+ paintNow();
370
+ ready = !takeMissed();
371
+ if (ready && !bakesPending()) {
372
+ showReady();
373
+ return;
374
+ }
375
+ if (!watch)
376
+ watch = onLanded(() => {
377
+ const p = showPaint;
378
+ if (canvasEl && p && !shownRef) showWhenBaked(p);
379
+ else stopWatch();
380
+ showReady();
381
+ });
382
+ }
383
+
384
+ function lightsFor(lightAt: number) {
385
+ const a = (lightAt * Math.PI) / 180;
386
+ const b = ((backLight ?? 0) * Math.PI) / 180;
387
+ const lights = stockLights(Math.sin(a), -Math.cos(a));
388
+ if (backLight !== undefined) [lights.bx, lights.by] = [Math.sin(b), -Math.cos(b)];
389
+ if (lightFront !== undefined) lights.front = (Math.min(85, Math.max(0, lightFront)) * Math.PI) / 180;
390
+ return lights;
391
+ }
392
+
393
+ function warm() {
394
+ if (!material || !cfg?.path) return;
395
+ const c = cfg;
396
+ const dev = (drawnSize || 64) * deviceDpr();
397
+ const lightAt = c.light ?? 295;
398
+ warmPlastic(outlineKey, c.path, dev, depth, shadingMode === 'fabric', fur, lightAt, lightsFor(lightAt));
399
+ }
400
+
401
+ function warmIdle(fn: () => void): () => void {
402
+ if (typeof requestIdleCallback === 'function') {
403
+ const id = requestIdleCallback(fn);
404
+ return () => cancelIdleCallback(id);
405
+ }
406
+ const id = setTimeout(fn, 1);
407
+ return () => clearTimeout(id);
408
+ }
409
+
410
+ function hold() {
411
+ warm();
412
+ if (!bakesPending()) return;
413
+ hurry(true);
414
+ const release = () => {
415
+ if (!holding) return;
416
+ holding();
417
+ holding = null;
418
+ paint();
419
+ };
420
+ const off = onLanded(() => {
421
+ if (!bakesPending()) release();
422
+ });
423
+ const t = setTimeout(release, SHOW_WITHIN);
424
+ holding = () => {
425
+ off();
426
+ clearTimeout(t);
427
+ hurry(false);
428
+ };
429
+ }
430
+
431
+ function handleClick(e: MouseEvent) {
432
+ if (interactive && !frozen && !heldPose) sim?.poke();
433
+ userClick?.(e);
434
+ }
435
+
436
+ onMount(() => () => {
437
+ stopWaiting();
438
+ holding?.();
439
+ holding = null;
440
+ });
441
+
442
+ function shouldHold() {
443
+ return shownRef && material && lastLook !== null && lastLook !== lookKey && !holding;
444
+ }
445
+
446
+ function applyPose() {
447
+ if (holding) return;
448
+ if (reducedMotion()) {
449
+ stillPaint = paintStill;
450
+ if (shownRef) settle(paintStill);
451
+ else showWhenBaked(paintStill);
452
+ return;
453
+ }
454
+ if (canvasEl) {
455
+ const c = cfg;
456
+ if (c) c.theme = resolveTheme(canvasEl);
457
+ }
458
+ if (!shownRef) {
459
+ stillPaint = frozen ? paint : null;
460
+ showWhenBaked(paint);
461
+ } else if (frozen) {
462
+ stillPaint = paint;
463
+ settle(paint);
464
+ } else {
465
+ stillPaint = null;
466
+ stopWaiting();
467
+ paint();
468
+ }
469
+ }
470
+
471
+ /* Sync the rig with the props on every meaningful change: this is the
472
+ React `useLayoutEffect` (which ran each render) made reactive. */
473
+ $effect(() => {
474
+ if (!sim) sim = new Sim(seedValue, stateKey);
475
+ else sim.setState(stateKey);
476
+ sim.setTurn(clamp(turn, 0, 2));
477
+ if (shouldHold()) hold();
478
+ lastLook = lookKey;
479
+ sim.setJump({
480
+ height: jumpHeight,
481
+ time: Math.max(0.2, jumpTime),
482
+ stretch: jumpStretch,
483
+ spin: Math.max(0, Math.round(jumpSpin)),
484
+ lean: jumpLean,
485
+ every: jumpEvery,
486
+ land: jumpLand,
487
+ squash: jumpSquash,
488
+ squashTime: Math.max(0.05, jumpSquashTime),
489
+ squashEase: jumpSquashEase,
490
+ groundTime: Math.max(0, jumpGroundTime),
491
+ groundEase: jumpGroundEase,
492
+ riseTime: Math.max(0.05, jumpRiseTime),
493
+ riseEase: jumpRiseEase,
494
+ clickSquashTime: Math.max(0.05, jumpClickSquashTime),
495
+ });
496
+ applyPose();
497
+ });
498
+
499
+ /* Register with the shared shown-gate so a row of baked avatars appears
500
+ together, or after SHOW_WITHIN at the latest. */
501
+ $effect(() => {
502
+ if (shown) return;
503
+ waiters.set(meRef, { ready: () => ready, show: markShown });
504
+ showReady();
505
+ hurry(true);
506
+ const t = setTimeout(markShown, SHOW_WITHIN);
507
+ return () => {
508
+ hurry(false);
509
+ clearTimeout(t);
510
+ stopWatch();
511
+ waiters.delete(meRef);
512
+ showReady();
513
+ };
514
+ });
515
+
516
+ /* Start the material's bakes on idle time as soon as the shape is known. */
517
+ $effect(() => {
518
+ void shadingMode;
519
+ void outlineKey;
520
+ void drawnSize;
521
+ void depth;
522
+ void light;
523
+ void backLight;
524
+ void lightFront;
525
+ if (!material || !cfg?.path) return;
526
+ return warmIdle(warm);
527
+ });
528
+
529
+ function pointerStrength(d: number) {
530
+ if (d < 1) return 1;
531
+ if (d > 3) return 0;
532
+ return 1 - (d - 1) / 2;
533
+ }
534
+
535
+ function updatePointer(s: Sim, canvas: HTMLCanvasElement) {
536
+ if (!interactiveNow || Number.isNaN(pointer.x)) {
537
+ s.setPointer(0, 0, 0);
538
+ return;
539
+ }
540
+ const r = canvas.getBoundingClientRect();
541
+ const box = r.width / OVERSCAN || 1;
542
+ const dx = (pointer.x - (r.left + r.width / 2)) / box;
543
+ const dy = (pointer.y - (r.top + r.height / 2 + RISE * box)) / box;
544
+ const d = Math.hypot(dx, dy);
545
+ s.setPointer(dx / Math.max(1, d), dy / Math.max(1, d), pointerStrength(d));
546
+ }
547
+
548
+ /* The loop, only while visible, animated and not reduced. */
549
+ $effect(() => {
550
+ if (frozen || reducedMotion()) return;
551
+ const canvas = canvasEl;
552
+ if (!canvas) return;
553
+ let unsub: (() => void) | null = null;
554
+ const tick = (dt: number) => {
555
+ const s = sim;
556
+ if (!s) return;
557
+ updatePointer(s, canvas);
558
+ s.update(dt * speed);
559
+ if (shownRef && !holding) paint();
560
+ };
561
+ const run = () => {
562
+ if (!unsub) unsub = subscribe(tick);
563
+ };
564
+ const stop = () => {
565
+ if (unsub) unsub();
566
+ unsub = null;
567
+ };
568
+ let io: IntersectionObserver | null = null;
569
+ if (typeof IntersectionObserver === 'function') {
570
+ io = new IntersectionObserver((entries) => {
571
+ if (entries[0]?.isIntersecting ?? true) run();
572
+ else stop();
573
+ });
574
+ io.observe(canvas);
575
+ } else run();
576
+ return () => {
577
+ stop();
578
+ if (io) io.disconnect();
579
+ };
580
+ });
581
+
582
+ const canvasStyle = $derived.by(() => {
583
+ const s = drawnSize || 64;
584
+ const dim = `${s * OVERSCAN}px`;
585
+ const side = (OVERSCAN - 1) / 2;
586
+ const pull = (k: number) => `${-s * k}px`;
587
+ return (
588
+ `display:inline-block;vertical-align:middle;width:${dim};height:${dim};` +
589
+ `margin-left:${pull(side)};margin-right:${pull(side)};` +
590
+ `margin-top:${pull(side + RISE)};margin-bottom:${pull(side - RISE)};` +
591
+ `flex:none;opacity:${shown ? 1 : 0};transition:opacity 0.3s ease-out`
592
+ );
593
+ });
594
+
595
+ /* No box: the slot carries no border, background or padding. Without an
596
+ explicit size it stays square, and the avatar fills three quarters of it. */
597
+ const hostStyle = $derived.by(() => {
598
+ const base =
599
+ size === undefined
600
+ ? 'display:inline-grid;place-items:center;line-height:0;width:100%;aspect-ratio:1/1;'
601
+ : 'display:inline-block;line-height:0;';
602
+ return style ? `${base}${style}` : base;
603
+ });
604
+
605
+ onMount(() => {
606
+ if (!hostEl) return;
607
+ const observer = new ResizeObserver(([entry]) => {
608
+ if (entry) hostWidth = entry.contentRect.width;
609
+ });
610
+ observer.observe(hostEl);
611
+ return () => observer.disconnect();
612
+ });
613
+
614
+ export function getCanvas(): HTMLCanvasElement | null {
615
+ return canvasEl;
616
+ }
617
+ </script>
618
+
619
+ <span
620
+ bind:this={hostEl}
621
+ class={className ? `agent-avatar ${className}` : 'agent-avatar'}
622
+ style={hostStyle}
623
+ data-agent-shape={type}
624
+ >
625
+ {#if drawnSize > 0}
626
+ <canvas
627
+ bind:this={canvasEl}
628
+ data-bot-avatar={type}
629
+ data-face={faceKind}
630
+ data-state={stateKey}
631
+ role="img"
632
+ aria-label={ariaLabel ??
633
+ (name ? `${name}${role ? `, ${role}` : ''}` : `${preset.label} agent, ${stateLabels[stateKey]}`)}
634
+ style={canvasStyle}
635
+ onclick={handleClick}
636
+ {...rest}
637
+ ></canvas>
638
+ {/if}
639
+ </span>