@craftedstudios/avatars 0.0.0-stage → 1.0.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/src/goo.js ADDED
@@ -0,0 +1,70 @@
1
+ // The hover lean. A drop of the avatar's own material follows the pointer on a
2
+ // spring and the shader melts it back into the body, so the avatar draws out
3
+ // toward the pointer with a neck that thins as it goes. Every length is in
4
+ // units of the radius r, so it feels the same at 16px and at 240px.
5
+ //
6
+ // Two numbers set it, both 0 to 1:
7
+ // stretch how far the drop reaches: a slight bulge at 0, a drip hanging
8
+ // off the edge with a neck back to the body at 1
9
+ // thickness how thick and slow it is: a thin neck that snaps back at 0, a
10
+ // heavy one that sinks back slowly at 1
11
+ // The spring is critically damped at every setting, so it never overshoots.
12
+
13
+ // The canvas reaches this far past the avatar's box on every side, in r. The
14
+ // drop is never allowed further than fits inside it.
15
+ export const PAD = 1;
16
+
17
+ const mix = (a, b, t) => a + (b - a) * t;
18
+
19
+ export function params(stretch = 1, thickness = 0.5) {
20
+ const s = Math.min(1, Math.max(0, stretch)), g = Math.min(1, Math.max(0, thickness));
21
+ const reach = mix(0.35, 1.6, s);
22
+ return {
23
+ reach, // the farthest the drop's center travels
24
+ range: reach + 0.8, // the pull starts within this distance of the center
25
+ drop: mix(0.42, 0.58, g), // the drop's radius at rest
26
+ thin: 0.3 * s, // how much smaller the drop gets at full reach
27
+ goo: mix(0.2, 0.7, g), // how far apart body and drop still melt together
28
+ bulge: mix(0.06, 0.1, s), // how much of the pull the body itself follows
29
+ out: mix(220, 80, g), // spring stiffness while reaching out
30
+ back: mix(70, 12, g), // spring stiffness while sinking back
31
+ };
32
+ }
33
+
34
+ // Where the drop wants to be, relative to the center, for a pointer at
35
+ // (dx, dy). It goes to the pointer, out to full reach, so past the edge it
36
+ // hangs off the avatar under the cursor. Further out than that it lets go
37
+ // smoothly by the end of the range.
38
+ export function target(dx, dy, r, g) {
39
+ const dist = Math.hypot(dx, dy);
40
+ if (!(dist > 0) || dist > g.range * r) return [0, 0];
41
+ const reach = Math.min(g.reach, 1 + PAD - g.drop * (1 - g.thin) - 0.05) * r;
42
+ const edge = Math.min(1, (g.range * r - dist) / Math.max(1e-3, g.range * r - reach));
43
+ const ease = edge * edge * (3 - 2 * edge);
44
+ const m = Math.min(dist, reach) * (dist > reach ? ease : 1);
45
+ return [(dx / dist) * m, (dy / dist) * m];
46
+ }
47
+
48
+ // Advances one avatar's drop state { x, y, vx, vy } toward (tx, ty) by dt
49
+ // seconds. Substepped so a dropped frame doesn't change how it moves.
50
+ export function step(s, tx, ty, dt, g) {
51
+ const outward = tx * tx + ty * ty > s.x * s.x + s.y * s.y;
52
+ const k = outward ? g.out : g.back;
53
+ const c = 2 * Math.sqrt(k);
54
+ let left = Math.min(dt, 0.1);
55
+ while (left > 1e-6) {
56
+ const h = Math.min(left, 1 / 240);
57
+ s.vx += (k * (tx - s.x) - c * s.vx) * h;
58
+ s.vy += (k * (ty - s.y) - c * s.vy) * h;
59
+ s.x += s.vx * h;
60
+ s.y += s.vy * h;
61
+ left -= h;
62
+ }
63
+ }
64
+
65
+ export const settled = (s, tx, ty, r) =>
66
+ Math.abs(tx - s.x) + Math.abs(ty - s.y) < 0.002 * r && Math.abs(s.vx) + Math.abs(s.vy) < 0.02 * r;
67
+
68
+ // The drop's radius for how far out it is.
69
+ export const dropRadius = (s, r, g) =>
70
+ r * g.drop * (1 - g.thin * Math.min(1, Math.hypot(s.x, s.y) / (g.reach * r)));
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { avatar } from './avatar.js';
2
+ export { cursor } from './cursor.js';
3
+ export { defineAvatarElement } from './element.js';
4
+ export { toDataURL, toBlob } from './still.js';
5
+ export { traits, EFFECTS, AUTO_EFFECTS } from './seed.js';
6
+ export { nameFor } from './names.js';
7
+ export { EFFECT_COLORS } from './color.js';
package/src/names.js ADDED
@@ -0,0 +1,44 @@
1
+ // Anonymous names for cursors, from the seed: a friendly adjective and an
2
+ // animal, like "Swift Otter". The same seed always gets the same name. The
3
+ // words say nothing about color: at cursor size an avatar shows whichever of
4
+ // its colors its layout puts in view, so a color name would often be wrong.
5
+ // Like the rest of the seed mapping, these lists are frozen once 1.0 ships:
6
+ // changing them renames everyone.
7
+
8
+ import { fnv1a32 } from './seed.js';
9
+ import { scramble } from './color.js';
10
+
11
+ // Only words that are kind to whoever ends up with them.
12
+ const ADJECTIVES = [
13
+ 'Swift', 'Quiet', 'Bright', 'Gentle', 'Brave', 'Calm', 'Clever', 'Curious',
14
+ 'Eager', 'Happy', 'Jolly', 'Lucky', 'Merry', 'Nimble', 'Plucky', 'Proud',
15
+ 'Sunny', 'Witty', 'Cozy', 'Daring', 'Dapper', 'Breezy', 'Chipper', 'Snappy',
16
+ 'Spry', 'Steady', 'Bold', 'Keen', 'Kind', 'Lively', 'Mellow', 'Noble',
17
+ 'Patient', 'Peppy', 'Quick', 'Radiant', 'Serene', 'Sharp', 'Smooth', 'Sparkly',
18
+ 'Stellar', 'Sturdy', 'Tidy', 'Upbeat', 'Vivid', 'Wise', 'Zippy', 'Cheery',
19
+ ];
20
+
21
+ const ANIMALS = [
22
+ 'Otter', 'Heron', 'Fox', 'Lynx', 'Owl', 'Hare', 'Finch', 'Wren',
23
+ 'Swan', 'Crane', 'Falcon', 'Robin', 'Sparrow', 'Magpie', 'Puffin', 'Pelican',
24
+ 'Koala', 'Panda', 'Badger', 'Beaver', 'Bison', 'Moose', 'Gazelle', 'Ibex',
25
+ 'Llama', 'Alpaca', 'Zebra', 'Giraffe', 'Tapir', 'Lemur', 'Gecko', 'Turtle',
26
+ 'Newt', 'Salmon', 'Marlin', 'Dolphin', 'Orca', 'Seal', 'Walrus', 'Narwhal',
27
+ 'Manatee', 'Octopus', 'Crab', 'Jellyfish', 'Bee', 'Moth', 'Beetle', 'Cricket',
28
+ 'Hedgehog', 'Squirrel', 'Ferret', 'Wolf', 'Tiger', 'Leopard', 'Jaguar', 'Ocelot',
29
+ 'Bear', 'Kiwi', 'Toucan', 'Parrot', 'Kestrel', 'Osprey', 'Penguin', 'Quail',
30
+ ];
31
+
32
+ /** The anonymous name a seed gets, like "Swift Otter". */
33
+ export function nameFor(seed) {
34
+ const pick = scramble(fnv1a32(String(seed ?? '')) ^ 0x85ebca6b);
35
+ return `${ADJECTIVES[pick % ADJECTIVES.length]} ${ANIMALS[(pick >>> 8) % ANIMALS.length]}`;
36
+ }
37
+
38
+ // What a cursor's tag says: the app's name for the person, or the seed's
39
+ // anonymous name when there isn't one, or nothing at all for `false`.
40
+ export function tagName(name, seed) {
41
+ if (name === false) return null;
42
+ const given = typeof name === 'string' ? name.trim() : name == null ? '' : String(name);
43
+ return given || nameFor(seed);
44
+ }
package/src/options.js ADDED
@@ -0,0 +1,88 @@
1
+ import { traits } from './seed.js';
2
+ import { css, effectColors, parseColor } from './color.js';
3
+
4
+ const GRADIENTS = new Set(['linear', 'plasma', 'radial', 'thermal']);
5
+
6
+ export const DEFAULTS = {
7
+ seed: '',
8
+ size: 32,
9
+ effect: 'auto',
10
+ colors: undefined,
11
+ motion: 'auto',
12
+ interaction: 'goo',
13
+ grain: 0.07,
14
+ speed: 1,
15
+ distortion: 0.5,
16
+ scale: 1,
17
+ stretch: 1,
18
+ thickness: 0.5,
19
+ shape: 'circle',
20
+ ring: 0,
21
+ ringColor: '#fff',
22
+ label: undefined,
23
+ };
24
+
25
+ const SHAPES = ['circle', 'squircle', 'square'];
26
+ // Goo stretches the avatar's outline toward the pointer, which still shows at
27
+ // 24px. The rest are Scenery's interactions, which move the pattern inside the
28
+ // outline and leave the shape still.
29
+ export const INTERACTIONS = ['goo', 'follow', 'repel', 'distort', 'pulse', 'orbit', 'turbulence', 'none'];
30
+ const RADIUS = { circle: '50%', squircle: '30%', square: '0' };
31
+
32
+ const num = (v, fallback, lo, hi) => {
33
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
34
+ return typeof n === 'number' && Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : fallback;
35
+ };
36
+
37
+ // Fills in defaults and clamps what came in, so the rest of the code can trust
38
+ // it. Accepts strings as well, because web component attributes are strings.
39
+ export function resolve(input = {}) {
40
+ const o = { ...DEFAULTS };
41
+ for (const k in input) if (input[k] !== undefined && k in DEFAULTS) o[k] = input[k];
42
+ o.seed = String(o.seed ?? '');
43
+ o.size = num(o.size, 32, 1, 2048);
44
+ o.grain = num(o.grain, 0.07, 0, 1);
45
+ o.speed = num(o.speed, 1, 0, 4);
46
+ o.distortion = num(o.distortion, 0.5, 0, 1);
47
+ o.scale = num(o.scale, 1, 0.25, 4);
48
+ o.stretch = num(o.stretch, 1, 0, 1);
49
+ o.thickness = num(o.thickness, 0.5, 0, 1);
50
+ o.ring = num(o.ring, 0, 0, 64);
51
+ // A list, or a comma-separated string from a web component attribute.
52
+ o.colors = typeof o.colors === 'string' ? o.colors.split(',').map((c) => c.trim()).filter(Boolean) : Array.isArray(o.colors) ? o.colors.slice(0, 4) : undefined;
53
+ if (o.colors && !o.colors.length) o.colors = undefined;
54
+ o.shape = SHAPES.includes(o.shape) ? o.shape : 'circle';
55
+ o.motion = o.motion === 'still' ? 'still' : 'auto';
56
+ o.interaction = INTERACTIONS.includes(o.interaction) ? o.interaction : 'goo';
57
+ o.label = o.label ? String(o.label) : undefined;
58
+ o.traits = traits(o.seed, o);
59
+ o.palette = effectColors(o.traits.effect, o.traits.hue, o.colors, o.traits.tone);
60
+ // Small, four seeded colors average into one muddy blob, so a gradient
61
+ // keeps its first two: the same avatar with its accents dropped.
62
+ if (o.size < 40 && GRADIENTS.has(o.traits.effect) && !(o.colors || []).some((c) => parseColor(c))) {
63
+ const [a, b] = o.palette;
64
+ o.palette = [a, b, b, b];
65
+ }
66
+ return o;
67
+ }
68
+
69
+ // The host element's inline style. The server and the client both produce it
70
+ // from the same options, which is what keeps hydration quiet: before WebGL
71
+ // takes over, this gradient is the avatar.
72
+ export function hostStyle(o) {
73
+ return {
74
+ display: 'inline-block',
75
+ position: 'relative',
76
+ flex: 'none',
77
+ verticalAlign: 'middle',
78
+ width: `${o.size}px`,
79
+ height: `${o.size}px`,
80
+ borderRadius: RADIUS[o.shape],
81
+ background: css(o.traits.effect, o.palette),
82
+ boxShadow: o.ring > 0 ? `inset 0 0 0 ${o.ring}px ${o.ringColor}` : 'none',
83
+ };
84
+ }
85
+
86
+ export function hostAttrs(o) {
87
+ return o.label ? { role: 'img', 'aria-label': o.label } : { 'aria-hidden': 'true' };
88
+ }
package/src/react.js ADDED
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+ // React components. They render the host on the server with the fallback
3
+ // gradient in place, so there is no empty circle or layout shift before the
4
+ // client loads, and the live canvas joins it after hydration.
5
+
6
+ import { createElement as h, useEffect, useId, useLayoutEffect, useRef } from 'react';
7
+ import { resolve, hostStyle, hostAttrs, DEFAULTS } from './options.js';
8
+ import { mount } from './avatar.js';
9
+ import { ARROW, cursorColors, placeStyle, sizes as cursorSizes, styles as cursorStyles } from './cursor.js';
10
+ import { tagName } from './names.js';
11
+
12
+ const useLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;
13
+ const KEYS = Object.keys(DEFAULTS);
14
+
15
+ function split(props) {
16
+ const opts = {}, rest = {};
17
+ for (const k in props) (KEYS.includes(k) ? opts : rest)[k] = props[k];
18
+ return [opts, rest];
19
+ }
20
+
21
+ function Live({ enter = false, className, style, ...props }) {
22
+ const [opts, rest] = split(props);
23
+ const o = resolve(opts);
24
+ const ref = useRef(null);
25
+ const live = useRef(null);
26
+ const latest = useRef(opts);
27
+ latest.current = opts;
28
+ const key = KEYS.map((k) => String(opts[k])).join('\u0000');
29
+
30
+ useLayout(() => { live.current?.update({ ...DEFAULTS, ...latest.current }); }, [key]);
31
+ useEffect(() => {
32
+ live.current = mount(ref.current, latest.current, { styled: true, enter });
33
+ return () => { live.current.destroy(); live.current = null; };
34
+ }, []);
35
+
36
+ return h('span', {
37
+ ...rest,
38
+ ref,
39
+ className: className ? `crafted-avatar ${className}` : 'crafted-avatar',
40
+ style: { ...hostStyle(o), ...style },
41
+ ...hostAttrs(o),
42
+ });
43
+ }
44
+
45
+ // <Avatar seed={user.id} size={32} />
46
+ export function Avatar(props) {
47
+ return h(Live, props);
48
+ }
49
+
50
+ // "Who's here": overlapping avatars with a +N for the rest.
51
+ // <AvatarStack seeds={ids} size={24} max={4} />
52
+ // A seed can also be an object with its own options, e.g.
53
+ // { seed: id, label: 'Ada', effect: 'radial' }
54
+ export function AvatarStack({ seeds = [], size = 24, max = 4, ring = 1.5, ringColor = '#fff', overlap = 0.25, className, style, label, ...shared }) {
55
+ const items = seeds.map((s) => (s !== null && typeof s === 'object' ? s : { seed: s }));
56
+ const shown = items.slice(0, Math.max(0, max));
57
+ const more = items.length - shown.length;
58
+ // Avatars that arrive after the stack first mounts grow in; the ones that
59
+ // were there at load don't, so a page doesn't open with everything popping.
60
+ const seen = useRef(null);
61
+ useEffect(() => {
62
+ seen.current ||= new Set();
63
+ for (const it of shown) seen.current.add(String(it.seed));
64
+ });
65
+ const shapeRadius = { squircle: '30%', square: '0' }[shared.shape] || '50%';
66
+
67
+ const faces = shown.map((it, i) => h(Live, {
68
+ key: String(it.seed),
69
+ ...shared,
70
+ ring,
71
+ ringColor,
72
+ size,
73
+ ...it,
74
+ enter: !!seen.current && !seen.current.has(String(it.seed)),
75
+ style: { marginLeft: i ? -size * overlap : 0, zIndex: shown.length - i, ...it.style },
76
+ }));
77
+ if (more > 0) {
78
+ faces.push(h('span', {
79
+ key: '+more',
80
+ className: 'crafted-avatar-more',
81
+ style: {
82
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none',
83
+ position: 'relative', boxSizing: 'border-box', width: size, height: size,
84
+ marginLeft: shown.length ? -size * overlap : 0, borderRadius: shapeRadius,
85
+ background: '#F1F2F3', color: '#070B0E', boxShadow: `inset 0 0 0 ${ring}px ${ringColor}`,
86
+ fontSize: Math.max(9, Math.round(size * 0.4)), lineHeight: 1, fontVariantNumeric: 'tabular-nums',
87
+ letterSpacing: '-0.02em',
88
+ },
89
+ 'aria-hidden': 'true',
90
+ }, `+${more}`));
91
+ }
92
+ return h('div', {
93
+ className: className ? `crafted-avatar-stack ${className}` : 'crafted-avatar-stack',
94
+ role: 'group',
95
+ 'aria-label': label ?? `${items.length} ${items.length === 1 ? 'person' : 'people'}`,
96
+ style: { display: 'inline-flex', alignItems: 'center', isolation: 'isolate', ...style },
97
+ }, faces);
98
+ }
99
+
100
+ // A presence cursor with the avatar built in. The app says where it is; the
101
+ // tip lands on x, y inside the nearest positioned ancestor.
102
+ // <Cursor seed={user.id} name={user.name} variant="avatar" x={x} y={y} />
103
+ // Without x and y it sits in the flow, which suits a preview or a legend.
104
+ export function Cursor({ variant = 'arrow', name, x, y, zoom = 1, className, style, ...options }) {
105
+ const gradient = `crafted-cursor-${useId().replace(/:/g, '')}`;
106
+ const colors = cursorColors(options);
107
+ const look = { ...options, interaction: 'none' };
108
+ const placed = typeof x === 'number' && typeof y === 'number';
109
+ const size = cursorSizes(zoom);
110
+ const box = variant === 'avatar' ? size.drop : size.arrow;
111
+ const root = placed
112
+ ? { ...cursorStyles.root, transform: placeStyle(variant, x, y, zoom) }
113
+ : { position: 'relative', display: 'inline-block', verticalAlign: 'top', width: box, height: box, pointerEvents: 'none' };
114
+
115
+ const pointer = variant === 'avatar'
116
+ ? h('span', { style: cursorStyles.drop(zoom) }, h(Live, { ...look, size: size.drop, shape: 'square' }))
117
+ : h('svg', { viewBox: '0 0 24 24', width: size.arrow, height: size.arrow, style: cursorStyles.arrow(zoom), 'aria-hidden': 'true' },
118
+ h('defs', null, h('linearGradient', { id: gradient, x1: 0.1, y1: 0.1, x2: 0.9, y2: 0.9 },
119
+ h('stop', { offset: 0, stopColor: colors[0] }),
120
+ h('stop', { offset: 1, stopColor: colors[1] }))),
121
+ h('path', { d: ARROW, fill: `url(#${gradient})`, stroke: '#fff', strokeWidth: 1.6, strokeLinejoin: 'round' }));
122
+
123
+ const label = tagName(name, options.seed);
124
+ const tag = label ? h('span', { style: cursorStyles.tag(variant, zoom) },
125
+ variant === 'avatar' ? null : h(Live, { ...look, size: size.dot }),
126
+ label) : null;
127
+
128
+ return h('span', {
129
+ className: className ? `crafted-cursor ${className}` : 'crafted-cursor',
130
+ 'aria-hidden': 'true',
131
+ style: { ...root, ...style },
132
+ }, pointer, tag);
133
+ }