@heyramzi/ui 8.0.0 → 8.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/README.md CHANGED
@@ -129,7 +129,7 @@ Inspired by [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte).
129
129
  The reveal, marquee and beam effects are inspired by
130
130
  [motion-primitives](https://github.com/ibelick/motion-primitives), and the
131
131
  motion vocabulary by [transitions-dev](https://transitions.dev). The agent avatars
132
- vendor [bot-avatars](https://github.com/jakubantalik/bot-avatars); its licence is in
132
+ port [bot-avatars](https://github.com/Jakubantalik/Libraries.dev/tree/main/packages/bot-avatars) 0.2.2; its licence is in
133
133
  `src/agents/vendor/LICENSE`.
134
134
 
135
135
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "8.0.0",
3
+ "version": "8.1.0",
4
4
  "description": "Svelte 5 UI primitives: effects, animations, and shared component CSS.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,55 +1,5 @@
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
1
  <script lang="ts">
52
- import { onMount } from 'svelte';
2
+ import { onMount, untrack } from 'svelte';
53
3
  import type {
54
4
  BotAvatarShading,
55
5
  BotAvatarState,
@@ -61,16 +11,29 @@
61
11
  import { autoInk, richer, shade } from './vendor/color';
62
12
  import { Sim, restPose } from './vendor/engine';
63
13
  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';
14
+ import { bakesPending, hurry, onLanded, takeMissed, warmPlastic, type FurStyle } from './vendor/plastic';
73
15
  import { subscribe, pointer } from './vendor/ticker';
16
+ import {
17
+ SHOW_WITHIN,
18
+ bodyPath,
19
+ nextUid,
20
+ onThemeChange,
21
+ partPaths,
22
+ showReady,
23
+ waiters,
24
+ } from './page-state';
25
+ import {
26
+ clamp,
27
+ clampOpt,
28
+ deviceDpr,
29
+ hashSeed,
30
+ lightsFor,
31
+ pointerStrength,
32
+ q,
33
+ reducedMotion,
34
+ resolveTheme as themeOf,
35
+ warmIdle,
36
+ } from './helpers';
74
37
 
75
38
  type Props = AgentAvatarProps;
76
39
 
@@ -83,7 +46,7 @@
83
46
  state: botState = 'default',
84
47
  size,
85
48
  color,
86
- ink = '#1E1A33',
49
+ ink,
87
50
  brightness: brightnessProp,
88
51
  saturation: saturationProp,
89
52
  speed = 1,
@@ -140,20 +103,14 @@
140
103
  class: className,
141
104
  style,
142
105
  'aria-label': ariaLabel,
106
+ 'aria-hidden': ariaHidden,
143
107
  onclick: userClick,
144
108
  ...rest
145
109
  }: Props = $props();
146
110
 
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
111
  const shapePath = (t: BotAvatarType) => SHAPE_PATHS[t] ?? SHAPE_PATHS.clover;
153
- const deviceDpr = () =>
154
- Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
155
112
 
156
- const uid = `ba-${++uidSeq}`;
113
+ const uid = nextUid();
157
114
 
158
115
  /* The avatar draws at 75% of its slot so an idle jump has room in the box
159
116
  and never clips. A numeric `size` overrides the measurement. */
@@ -205,6 +162,10 @@
205
162
  } satisfies FurStyle);
206
163
  const material = $derived(shadingMode === 'plastic' || shadingMode === 'fabric');
207
164
  const lookKey = $derived(`${shadingMode}|${outlineKey}|${Math.round(drawnSize)}`);
165
+ /* What the bakes depend on, as strings, so the warm effect re-runs only
166
+ when one of them changes, not on every prop or every resized pixel. */
167
+ const warmSize = $derived(Math.round(drawnSize));
168
+ const furKey = $derived(`${fur.length}|${fur.density}|${fur.fuzz}|${fur.clumps}|${fur.curl}|${fur.gravity}`);
208
169
  const interactiveNow = $derived(interactive && !heldPose);
209
170
 
210
171
  /* Captured once, on purpose: `shown` never returns to false, so a later
@@ -278,19 +239,7 @@
278
239
  let holding: (() => void) | null = null;
279
240
  const meRef = {};
280
241
 
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
- }
242
+ const resolveTheme = (el: HTMLElement | null) => themeOf(theme, el);
294
243
 
295
244
  function layoutPx(canvas: HTMLCanvasElement) {
296
245
  return canvas.clientWidth / OVERSCAN || cssSize || drawnSize || 64;
@@ -365,6 +314,9 @@
365
314
 
366
315
  function showWhenBaked(paintNow: () => void) {
367
316
  showPaint = paintNow;
317
+ /* No canvas means nothing was drawn, so nothing could report a missed
318
+ bake. Calling that ready would skip the fade. */
319
+ if (!canvasEl) return;
368
320
  takeMissed();
369
321
  paintNow();
370
322
  ready = !takeMissed();
@@ -381,30 +333,13 @@
381
333
  });
382
334
  }
383
335
 
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
336
  function warm() {
394
337
  if (!material || !cfg?.path) return;
395
338
  const c = cfg;
396
339
  const dev = (drawnSize || 64) * deviceDpr();
397
340
  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);
341
+ const lights = lightsFor(lightAt, backLight, lightFront);
342
+ warmPlastic(outlineKey, c.path, dev, depth, shadingMode === 'fabric', fur, lightAt, lights);
408
343
  }
409
344
 
410
345
  function hold() {
@@ -415,7 +350,7 @@
415
350
  if (!holding) return;
416
351
  holding();
417
352
  holding = null;
418
- paint();
353
+ applyPose();
419
354
  };
420
355
  const off = onLanded(() => {
421
356
  if (!bakesPending()) release();
@@ -471,6 +406,8 @@
471
406
  /* Sync the rig with the props on every meaningful change: this is the
472
407
  React `useLayoutEffect` (which ran each render) made reactive. */
473
408
  $effect(() => {
409
+ /* Until the slot is measured there's no canvas to draw on. */
410
+ if (drawnSize <= 0) return;
474
411
  if (!sim) sim = new Sim(seedValue, stateKey);
475
412
  else sim.setState(stateKey);
476
413
  sim.setTurn(clamp(turn, 0, 2));
@@ -513,25 +450,36 @@
513
450
  };
514
451
  });
515
452
 
453
+ /* Once shown, a paused or reduced-motion avatar draws only when asked, so
454
+ it repaints here and keeps repainting as its bakes land. */
455
+ $effect(() => {
456
+ if (!shown) return;
457
+ untrack(() => {
458
+ if (canvasEl && drawnSize > 0) applyPose();
459
+ });
460
+ });
461
+
462
+ $effect(() => {
463
+ if (theme !== 'auto') return;
464
+ return onThemeChange(() => {
465
+ if (canvasEl && drawnSize > 0) applyPose();
466
+ });
467
+ });
468
+
516
469
  /* Start the material's bakes on idle time as soon as the shape is known. */
517
470
  $effect(() => {
518
471
  void shadingMode;
519
472
  void outlineKey;
520
- void drawnSize;
473
+ void warmSize;
521
474
  void depth;
522
475
  void light;
523
476
  void backLight;
524
477
  void lightFront;
525
- if (!material || !cfg?.path) return;
478
+ void furKey;
479
+ if (!material || !warmSize || !untrack(() => cfg?.path)) return;
526
480
  return warmIdle(warm);
527
481
  });
528
482
 
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
483
  function updatePointer(s: Sim, canvas: HTMLCanvasElement) {
536
484
  if (!interactiveNow || Number.isNaN(pointer.x)) {
537
485
  s.setPointer(0, 0, 0);
@@ -604,8 +552,10 @@
604
552
 
605
553
  onMount(() => {
606
554
  if (!hostEl) return;
555
+ /* A hidden parent (a closed tab or panel) reports 0. Keep the last width
556
+ so the canvas, its picture and its gate survive being hidden. */
607
557
  const observer = new ResizeObserver(([entry]) => {
608
- if (entry) hostWidth = entry.contentRect.width;
558
+ if (entry && entry.contentRect.width > 0) hostWidth = entry.contentRect.width;
609
559
  });
610
560
  observer.observe(hostEl);
611
561
  return () => observer.disconnect();
@@ -628,9 +578,12 @@
628
578
  data-bot-avatar={type}
629
579
  data-face={faceKind}
630
580
  data-state={stateKey}
631
- role="img"
632
- aria-label={ariaLabel ??
633
- (name ? `${name}${role ? `, ${role}` : ''}` : `${preset.label} agent, ${stateLabels[stateKey]}`)}
581
+ role={ariaHidden ? undefined : 'img'}
582
+ aria-hidden={ariaHidden ? 'true' : undefined}
583
+ aria-label={ariaHidden
584
+ ? undefined
585
+ : (ariaLabel ??
586
+ (name ? `${name}${role ? `, ${role}` : ''}` : `${preset.label} agent, ${stateLabels[stateKey]}`))}
634
587
  style={canvasStyle}
635
588
  onclick={handleClick}
636
589
  {...rest}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import AgentAvatar from './agent-avatar.svelte';
3
- import type { Agent } from './types';
3
+ import type { Agent, AgentState } from './types';
4
4
 
5
5
  type Props = {
6
6
  /** The cast to lay out. */
@@ -32,6 +32,12 @@
32
32
 
33
33
  const keyOf = (agent: Agent) => agent.id ?? agent.name;
34
34
 
35
+ /* The card under the pointer or focus wakes up and works; the rest idle.
36
+ A `state` in avatarProps wins. */
37
+ let awake = $state<string | null>(null);
38
+ const stateFor = (id: string) =>
39
+ (avatarProps.state as AgentState | undefined) ?? (awake === id ? 'working' : 'default');
40
+
35
41
  /* Group when any agent asks for one, otherwise a single flat grid. */
36
42
  const sections = $derived.by(() => {
37
43
  const anyGroup = agents.some((agent) => agent.group);
@@ -72,8 +78,12 @@
72
78
  <button
73
79
  type="button"
74
80
  class="agent-cast__card"
75
- aria-current={id === selectedId ? 'true' : undefined}
81
+ aria-pressed={onSelect ? id === selectedId : undefined}
76
82
  onclick={() => onSelect?.(agent)}
83
+ onpointerenter={() => (awake = id)}
84
+ onpointerleave={() => awake === id && (awake = null)}
85
+ onfocus={() => (awake = id)}
86
+ onblur={() => awake === id && (awake = null)}
77
87
  >
78
88
  <span class="agent-cast__slot">
79
89
  <AgentAvatar
@@ -81,7 +91,9 @@
81
91
  role={agent.role}
82
92
  shape={agent.shape ?? 'clover'}
83
93
  color={agent.color}
94
+ aria-hidden
84
95
  {...avatarProps}
96
+ state={stateFor(id)}
85
97
  />
86
98
  </span>
87
99
  <span class="agent-cast__meta">
@@ -133,11 +145,21 @@
133
145
  border-radius: 0.75rem;
134
146
  cursor: pointer;
135
147
  transition:
136
- border-color 0.15s ease,
137
- transform 0.15s ease;
148
+ border-color var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
149
+ transform var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
138
150
  }
139
- .agent-cast__card:hover {
140
- border-color: color-mix(in oklab, var(--color-foreground, #18181b) 25%, transparent);
151
+ @media (prefers-reduced-motion: reduce) {
152
+ .agent-cast__card {
153
+ transition: border-color var(--duration-quick, 150ms) linear;
154
+ }
155
+ .agent-cast__card:active {
156
+ transform: none;
157
+ }
158
+ }
159
+ @media (hover: hover) and (pointer: fine) {
160
+ .agent-cast__card:hover {
161
+ border-color: color-mix(in oklab, var(--color-foreground, #18181b) 25%, transparent);
162
+ }
141
163
  }
142
164
  .agent-cast__card:active {
143
165
  transform: scale(0.985);
@@ -146,13 +168,15 @@
146
168
  outline: 2px solid var(--color-ring, #6366f1);
147
169
  outline-offset: 2px;
148
170
  }
149
- .agent-cast__card[aria-current='true'] {
171
+ .agent-cast__card[aria-pressed='true'] {
150
172
  border-color: var(--color-accent, #6366f1);
151
173
  box-shadow: 0 0 0 1px var(--color-accent, #6366f1);
152
174
  }
175
+ /* Room above the head, so a hop or a hat never meets the card's edge. */
153
176
  .agent-cast__slot {
154
177
  display: block;
155
178
  width: 100%;
179
+ padding: 0.5rem 0.5rem 0;
156
180
  }
157
181
  .agent-cast__meta {
158
182
  display: flex;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import AgentAvatar from './agent-avatar.svelte';
3
3
  import AgentCast from './agent-cast.svelte';
4
- import type { Agent } from './types';
4
+ import type { Agent, AgentState } from './types';
5
5
 
6
6
  const cast: Agent[] = [
7
7
  { id: 'ada', name: 'Ada', role: 'Researcher', shape: 'clover', color: '#41C4FF', group: 'Research' },
@@ -14,7 +14,14 @@
14
14
  { id: 'hal', name: 'Hal', role: 'Watcher', shape: 'star', group: 'Craft' },
15
15
  ];
16
16
 
17
+ const states: { value: AgentState; label: string }[] = [
18
+ { value: 'default', label: 'Idle' },
19
+ { value: 'working', label: 'Working' },
20
+ { value: 'sleeping', label: 'Sleeping' },
21
+ ];
22
+
17
23
  let selected = $state<string | null>('ada');
24
+ let leadState = $state<AgentState>('working');
18
25
  let lead = $derived(cast.find((agent) => agent.id === selected) ?? cast[0]);
19
26
  </script>
20
27
 
@@ -26,14 +33,25 @@
26
33
  role={lead.role}
27
34
  shape={lead.shape}
28
35
  color={lead.color}
29
- state="working"
30
- theme="light"
36
+ state={leadState}
31
37
  />
32
38
  </div>
33
39
  <div class="demo__intro">
34
40
  <p class="demo__eyebrow">AgentCast</p>
35
41
  <h1 class="demo__title">{lead.name}</h1>
36
42
  <p class="demo__role">{lead.role}</p>
43
+ <div class="demo__states" role="radiogroup" aria-label="State">
44
+ {#each states as s (s.value)}
45
+ <button
46
+ type="button"
47
+ role="radio"
48
+ aria-checked={leadState === s.value}
49
+ onclick={() => (leadState = s.value)}
50
+ >
51
+ {s.label}
52
+ </button>
53
+ {/each}
54
+ </div>
37
55
  </div>
38
56
  </header>
39
57
 
@@ -45,9 +63,8 @@
45
63
  max-width: 56rem;
46
64
  margin: 0 auto;
47
65
  padding: 2rem 1.5rem 4rem;
48
- font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
49
- color: #18181b;
50
- background: #fafafa;
66
+ color: var(--color-foreground, #18181b);
67
+ background: var(--color-background, #fafafa);
51
68
  }
52
69
  .demo__hero {
53
70
  display: flex;
@@ -64,7 +81,7 @@
64
81
  font-size: 0.75rem;
65
82
  letter-spacing: 0.14em;
66
83
  text-transform: uppercase;
67
- color: #71717a;
84
+ color: var(--color-muted-foreground, #71717a);
68
85
  }
69
86
  .demo__title {
70
87
  margin: 0.25rem 0 0;
@@ -73,6 +90,36 @@
73
90
  }
74
91
  .demo__role {
75
92
  margin: 0.25rem 0 0;
76
- color: #52525b;
93
+ color: var(--color-muted-foreground, #52525b);
94
+ }
95
+ .demo__states {
96
+ display: inline-flex;
97
+ gap: 0.25rem;
98
+ margin-top: 0.875rem;
99
+ padding: 0.1875rem;
100
+ border-radius: 0.625rem;
101
+ background: color-mix(in oklab, var(--color-foreground, #18181b) 6%, transparent);
102
+ }
103
+ .demo__states button {
104
+ padding: 0.3125rem 0.75rem;
105
+ font: inherit;
106
+ font-size: 0.8125rem;
107
+ color: var(--color-muted-foreground, #71717a);
108
+ background: transparent;
109
+ border: 0;
110
+ border-radius: 0.4375rem;
111
+ cursor: pointer;
112
+ transition:
113
+ color var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
114
+ background-color var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
115
+ }
116
+ .demo__states button[aria-checked='true'] {
117
+ color: var(--color-foreground, #18181b);
118
+ background: var(--color-card, #fff);
119
+ box-shadow: var(--elev-hairline, 0 0 0 1px rgb(0 0 0 / 0.06));
120
+ }
121
+ .demo__states button:focus-visible {
122
+ outline: 2px solid var(--color-ring, #6366f1);
123
+ outline-offset: 1px;
77
124
  }
78
125
  </style>
@@ -0,0 +1,58 @@
1
+ /* Pure helpers for the avatar: numbers, theme, lights, idle scheduling. */
2
+ import { stockLights } from './vendor/plastic';
3
+
4
+ export const clamp = (v: number, lo: number, hi: number) =>
5
+ Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 1));
6
+ export const q = (v: number, lo: number, hi: number) => Math.round(clamp(v, lo, hi) * 20) / 20;
7
+ export const clampOpt = (v: number | undefined, d: number, lo: number, hi: number) =>
8
+ clamp(v ?? d, lo, hi);
9
+ export const deviceDpr = () =>
10
+ Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
11
+
12
+ export function hashSeed(id: string): number {
13
+ let h = 2166136261;
14
+ for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619);
15
+ return ((h >>> 0) % 1000) / 1000;
16
+ }
17
+
18
+ export const reducedMotion = (): boolean =>
19
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
20
+
21
+ export function resolveTheme(theme: 'auto' | 'dark' | 'light', el: HTMLElement | null): 'dark' | 'light' {
22
+ if (theme !== 'auto') return theme;
23
+ const host = el?.closest('[data-theme], .dark, .light') as HTMLElement | null;
24
+ if (host) {
25
+ const v = host.getAttribute('data-theme');
26
+ if (v === 'dark' || v === 'light') return v;
27
+ if (host.classList.contains('dark')) return 'dark';
28
+ if (host.classList.contains('light')) return 'light';
29
+ }
30
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches
31
+ ? 'light'
32
+ : 'dark';
33
+ }
34
+
35
+ export function lightsFor(lightAt: number, backLight?: number, lightFront?: number) {
36
+ const a = (lightAt * Math.PI) / 180;
37
+ const b = ((backLight ?? 0) * Math.PI) / 180;
38
+ const lights = stockLights(Math.sin(a), -Math.cos(a));
39
+ if (backLight !== undefined) [lights.bx, lights.by] = [Math.sin(b), -Math.cos(b)];
40
+ if (lightFront !== undefined) lights.front = (Math.min(85, Math.max(0, lightFront)) * Math.PI) / 180;
41
+ return lights;
42
+ }
43
+
44
+ export function warmIdle(fn: () => void): () => void {
45
+ if (typeof requestIdleCallback === 'function') {
46
+ const id = requestIdleCallback(fn);
47
+ return () => cancelIdleCallback(id);
48
+ }
49
+ const id = setTimeout(fn, 1);
50
+ return () => clearTimeout(id);
51
+ }
52
+
53
+ /* Full pull up close, fading to nothing by 3 head widths. */
54
+ export function pointerStrength(d: number) {
55
+ if (d < 1) return 1;
56
+ if (d > 3) return 0;
57
+ return 1 - (d - 1) / 2;
58
+ }
@@ -0,0 +1,70 @@
1
+ /* What every avatar on the page shares: the batching shown-gate, so a row of
2
+ them comes in together, the path caches, and one theme watcher. */
3
+ import { bakesPending } from './vendor/plastic';
4
+
5
+ export const SHOW_WITHIN = 4000;
6
+ export const waiters = new Map<object, { ready: () => boolean; show: () => void }>();
7
+
8
+ export function showReady() {
9
+ if (bakesPending()) return;
10
+ for (const w of waiters.values()) if (!w.ready()) return;
11
+ const all = [...waiters.values()];
12
+ waiters.clear();
13
+ for (const w of all) w.show();
14
+ }
15
+
16
+ const pathCache = new Map<string, Path2D>();
17
+ export function bodyPath(d: string): Path2D {
18
+ let p = pathCache.get(d);
19
+ if (!p) {
20
+ p = new Path2D(d);
21
+ pathCache.set(d, p);
22
+ }
23
+ return p;
24
+ }
25
+
26
+ const partsCache = new Map<string, Path2D[]>();
27
+ export function partPaths(d: string): Path2D[] {
28
+ let p = partsCache.get(d);
29
+ if (!p) {
30
+ p = d
31
+ .split(/(?=M)/)
32
+ .filter((s) => s.trim())
33
+ .map((s) => new Path2D(s));
34
+ partsCache.set(d, p);
35
+ }
36
+ return p;
37
+ }
38
+
39
+ let uidSeq = 0;
40
+ export const nextUid = () => `ba-${++uidSeq}`;
41
+
42
+ /* One observer for the whole page: a `.dark` or `data-theme` flip on
43
+ <html> or <body>, or the OS scheme changing, repaints every auto avatar. */
44
+ const themeListeners = new Set<() => void>();
45
+ let themeWatch: (() => void) | null = null;
46
+ export function onThemeChange(fn: () => void): () => void {
47
+ themeListeners.add(fn);
48
+ if (!themeWatch && typeof MutationObserver === 'function' && typeof document !== 'undefined') {
49
+ const fire = () => {
50
+ for (const l of themeListeners) l();
51
+ };
52
+ const mo = new MutationObserver(fire);
53
+ const opts = { attributes: true, attributeFilter: ['class', 'data-theme'] };
54
+ mo.observe(document.documentElement, opts);
55
+ if (document.body) mo.observe(document.body, opts);
56
+ const mq = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
57
+ mq?.addEventListener('change', fire);
58
+ themeWatch = () => {
59
+ mo.disconnect();
60
+ mq?.removeEventListener('change', fire);
61
+ };
62
+ }
63
+ return () => {
64
+ themeListeners.delete(fn);
65
+ if (themeListeners.size === 0 && themeWatch) {
66
+ themeWatch();
67
+ themeWatch = null;
68
+ }
69
+ };
70
+ }
@@ -61,7 +61,7 @@ export type AgentAvatarProps = Omit<
61
61
  shape?: AgentShape;
62
62
  /** Face kind. Default `eyes`. */
63
63
  face?: BotAvatarFace;
64
- /** Face ink. Default `#1E1A33`. */
64
+ /** Face ink. Default: dark on a light body, light on a dark one. */
65
65
  ink?: string;
66
66
  /** Rendered size in px. Omit to fill 75% of the slot. */
67
67
  size?: number;
@@ -69,4 +69,6 @@ export type AgentAvatarProps = Omit<
69
69
  style?: string;
70
70
  onclick?: (event: MouseEvent) => void;
71
71
  'aria-label'?: string;
72
+ /** Hide the canvas from assistive tech when a parent already names the agent. */
73
+ 'aria-hidden'?: boolean | 'true' | 'false';
72
74
  };