@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 +1 -1
- package/package.json +1 -1
- package/src/agents/agent-avatar.svelte +67 -114
- package/src/agents/agent-cast.svelte +31 -7
- package/src/agents/demo.svelte +55 -8
- package/src/agents/helpers.ts +58 -0
- package/src/agents/page-state.ts +70 -0
- package/src/agents/types.ts +3 -1
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
|
-
|
|
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,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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
473
|
+
void warmSize;
|
|
521
474
|
void depth;
|
|
522
475
|
void light;
|
|
523
476
|
void backLight;
|
|
524
477
|
void lightFront;
|
|
525
|
-
|
|
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=
|
|
632
|
-
aria-
|
|
633
|
-
|
|
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-
|
|
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.
|
|
137
|
-
transform 0.
|
|
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
|
-
|
|
140
|
-
|
|
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-
|
|
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;
|
package/src/agents/demo.svelte
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
49
|
-
|
|
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
|
+
}
|
package/src/agents/types.ts
CHANGED
|
@@ -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
|
|
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
|
};
|