@quitscope/discord-welcomecard 0.1.0 → 0.2.1

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
@@ -1,102 +1,112 @@
1
- # @quitscope/discord-welcomecard
2
-
3
- Render Discord welcome cards as static **PNG** or animated **GIF** from one builder.
4
-
5
- ![centered preset](examples/centered.png)
6
-
7
- - **One API, two outputs** — the same builder renders `.toPNG()` and `.toGIF()`.
8
- - **Animated GIFs** — background sheen, text fade-in, pulsing avatar glow. Autoplays in Discord.
9
- - **No compile pain** — built on `@napi-rs/canvas` (prebuilt binaries, no node-gyp).
10
- - **Bundled font** — cards look the same on every server.
11
- - **TypeScript** — full types, ESM + CJS.
12
-
13
- ## Install
14
-
15
- ```bash
16
- npm install @quitscope/discord-welcomecard
17
- ```
18
-
19
- ## Usage
20
-
21
- ```ts
22
- import { WelcomeCard } from '@quitscope/discord-welcomecard';
23
- import { toAttachment } from '@quitscope/discord-welcomecard/discord';
24
-
25
- const card = new WelcomeCard()
26
- .setPreset('centered')
27
- .setUsername('Quit')
28
- .setAvatar('https://cdn.discordapp.com/avatars/.../avatar.png')
29
- .setSubtitle('Welcome to the server!')
30
- .setMemberCount(1234)
31
- .setTheme('dark')
32
- .setAnimations(['background', 'text', 'avatar']);
33
-
34
- const png = await card.toPNG();
35
- const gif = await card.toGIF();
36
-
37
- // discord.js
38
- channel.send({ files: [toAttachment(gif, 'welcome.gif')] });
39
- ```
40
-
41
- ### With discord.js events
42
-
43
- ```ts
44
- client.on('guildMemberAdd', async (member) => {
45
- const gif = await new WelcomeCard()
46
- .setUsername(member.user.displayName)
47
- .setAvatar(member.user.displayAvatarURL({ extension: 'png', size: 256 }))
48
- .setSubtitle(`Welcome to ${member.guild.name}!`)
49
- .setMemberCount(member.guild.memberCount)
50
- .setAnimations(['text', 'avatar'])
51
- .toGIF();
52
-
53
- const channel = member.guild.systemChannel;
54
- channel?.send({ files: [toAttachment(gif, 'welcome.gif')] });
55
- });
56
- ```
57
-
58
- ## Presets
59
-
60
- | `centered` (default) | `neon` |
61
- | --- | --- |
62
- | ![centered](examples/centered.png) | ![neon](examples/neon.png) |
63
-
64
- | `minimal` | `hero` |
65
- | --- | --- |
66
- | ![minimal](examples/minimal.png) | ![hero](examples/hero.png) |
67
-
68
- ## Animated
69
-
70
- `setAnimations([...])` + `toGIF()` — background sheen, text fade-in, avatar glow:
71
-
72
- ![animated](examples/animated.gif)
73
-
74
- ## API
75
-
76
- | Method | Description |
77
- | --- | --- |
78
- | `setPreset(name)` | `'centered'` (default), `'neon'`, `'minimal'`, `'hero'` |
79
- | `setUsername(name)` | Main text (required) |
80
- | `setAvatar(urlOrBuffer)` | Avatar image; falls back to a colored circle if it fails to load |
81
- | `setSubtitle(text)` | Secondary line, e.g. "Welcome to the server!" |
82
- | `setMemberCount(n)` | Renders "MEMBER #n" — optional, omit to hide |
83
- | `setMemberCountPosition(pos)` | 3×3 grid: `'top-left'`, `'top-center'`, `'top-right'`, `'center-left'`, `'center'`, `'center-right'`, `'bottom-left'`, `'bottom-center'`, `'bottom-right'`. `'corner'` is an alias for `'bottom-right'`. Defaults: `'bottom-center'` in the centered presets, `'bottom-right'` in `hero`. |
84
- | `setBackground(value)` | Hex color (`#1e1e2e`) or image URL |
85
- | `setTheme(theme)` | `'dark'` (default) or `'light'` |
86
- | `setFont({ family, color, size })` | Override the bundled font settings |
87
- | `setAnimations(list)` | Any of `'background'`, `'text'`, `'avatar'` — used by `toGIF()` |
88
- | `toPNG()` | `Promise<Buffer>` static card |
89
- | `toGIF()` | `Promise<Buffer>` animated card |
90
-
91
- The `toAttachment(buffer, name?)` helper lives in `@quitscope/discord-welcomecard/discord` and requires
92
- `discord.js` (optional peer dependency). The core package works without it.
93
-
94
- Only `setUsername()` is required everything else has sensible defaults or fallbacks.
95
-
96
- ## Requirements
97
-
98
- - Node.js 18
99
-
100
- ## License
101
-
102
- MIT. Font: [Poppins](https://fonts.google.com/specimen/Poppins) (SIL Open Font License, bundled).
1
+ # @quitscope/discord-welcomecard
2
+
3
+ Render Discord welcome cards as static **PNG** or animated **GIF** from one builder.
4
+
5
+ ![centered preset](examples/centered.png)
6
+
7
+ - **One API, two outputs** — the same builder renders `.toPNG()` and `.toGIF()`.
8
+ - **Animated GIFs** — background sheen, text fade-in, avatar glow/bounce, rainbow ring, slide-in. Autoplays in Discord.
9
+ - **No compile pain** — built on `@napi-rs/canvas` (prebuilt binaries, no node-gyp).
10
+ - **Bundled font** — cards look the same on every server.
11
+ - **TypeScript** — full types, ESM + CJS.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @quitscope/discord-welcomecard
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { WelcomeCard } from '@quitscope/discord-welcomecard';
23
+ import { toAttachment } from '@quitscope/discord-welcomecard/discord';
24
+
25
+ const card = new WelcomeCard()
26
+ .setPreset('centered')
27
+ .setUsername('Quit')
28
+ .setAvatar('https://cdn.discordapp.com/avatars/.../avatar.png')
29
+ .setSubtitle('Welcome to the server!')
30
+ .setMemberCount(1234)
31
+ .setTheme('dark')
32
+ .setAnimations(['background', 'text', 'avatar']);
33
+
34
+ const png = await card.toPNG();
35
+ const gif = await card.toGIF();
36
+
37
+ // discord.js
38
+ channel.send({ files: [toAttachment(gif, 'welcome.gif')] });
39
+ ```
40
+
41
+ ### With discord.js events
42
+
43
+ ```ts
44
+ client.on('guildMemberAdd', async (member) => {
45
+ const gif = await new WelcomeCard()
46
+ .setUsername(member.user.displayName)
47
+ .setAvatar(member.user.displayAvatarURL({ extension: 'png', size: 256 }))
48
+ .setSubtitle(`Welcome to ${member.guild.name}!`)
49
+ .setMemberCount(member.guild.memberCount)
50
+ .setAnimations(['text', 'avatar'])
51
+ .toGIF();
52
+
53
+ const channel = member.guild.systemChannel;
54
+ channel?.send({ files: [toAttachment(gif, 'welcome.gif')] });
55
+ });
56
+ ```
57
+
58
+ ## Presets
59
+
60
+ | `centered` (default) | `neon` |
61
+ | --- | --- |
62
+ | ![centered](examples/centered.png) | ![neon](examples/neon.png) |
63
+
64
+ | `minimal` | `hero` |
65
+ | --- | --- |
66
+ | ![minimal](examples/minimal.png) | ![hero](examples/hero.png) |
67
+
68
+ ## Animated
69
+
70
+ `setAnimations([...])` + `toGIF()` — combine any of the 6 animation types:
71
+
72
+ | Type | Effect |
73
+ | --- | --- |
74
+ | `'background'` | Diagonal sheen sweeps across the card |
75
+ | `'text'` | Text fades in (ease-out reveal) |
76
+ | `'avatar'` | Avatar ring pulses with a glow |
77
+ | `'ring'` | Ring color cycles through the full color wheel |
78
+ | `'slide'` | Text slides up into position from below |
79
+ | `'bounce'` | Avatar bounces up and down |
80
+
81
+ ![animated](examples/animated.gif)
82
+
83
+ ## API
84
+
85
+ | Method | Description |
86
+ | --- | --- |
87
+ | `setPreset(name)` | `'centered'` (default), `'neon'`, `'minimal'`, `'hero'` |
88
+ | `setUsername(name)` | Main text (required) |
89
+ | `setAvatar(urlOrBuffer)` | Avatar image; falls back to a colored circle if it fails to load |
90
+ | `setSubtitle(text)` | Secondary line, e.g. "Welcome to the server!" |
91
+ | `setMemberCount(n)` | Renders "MEMBER #n" optional, omit to hide |
92
+ | `setMemberCountPosition(pos)` | 3×3 grid: `'top-left'`, `'top-center'`, `'top-right'`, `'center-left'`, `'center'`, `'center-right'`, `'bottom-left'`, `'bottom-center'`, `'bottom-right'`. `'corner'` is an alias for `'bottom-right'`. Defaults: `'bottom-center'` in the centered presets, `'bottom-right'` in `hero`. |
93
+ | `setBackground(value)` | Hex color (`#1e1e2e`), image URL, or `Buffer` |
94
+ | `setRingColor(hex)` | Override the avatar ring / glow color |
95
+ | `setTheme(theme)` | `'dark'` (default) or `'light'` |
96
+ | `setFont({ family, color, usernameColor, size, subtitleSize })` | Override font settings; `usernameColor` applies only to the username line |
97
+ | `setAnimations(list)` | Any of `'background'`, `'text'`, `'avatar'`, `'ring'`, `'slide'`, `'bounce'` — used by `toGIF()` |
98
+ | `toPNG()` | `Promise<Buffer>` — static card |
99
+ | `toGIF()` | `Promise<Buffer>` — animated card |
100
+
101
+ The `toAttachment(buffer, name?)` helper lives in `@quitscope/discord-welcomecard/discord` and requires
102
+ `discord.js` (optional peer dependency). The core package works without it.
103
+
104
+ Only `setUsername()` is required — everything else has sensible defaults or fallbacks.
105
+
106
+ ## Requirements
107
+
108
+ - Node.js ≥ 18
109
+
110
+ ## License
111
+
112
+ MIT. Font: [Poppins](https://fonts.google.com/specimen/Poppins) (SIL Open Font License, bundled).
package/dist/index.cjs CHANGED
@@ -30,11 +30,11 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
30
30
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
31
31
 
32
32
  // src/constants.ts
33
- var CARD_WIDTH = 1024;
34
- var CARD_HEIGHT = 384;
33
+ var CARD_WIDTH = 840;
34
+ var CARD_HEIGHT = 420;
35
35
  var GIF_FRAMES = 30;
36
36
  var GIF_DELAY_MS = 50;
37
- var GIF_SCALE = 0.5;
37
+ var GIF_SCALE = 1;
38
38
  var DEFAULT_OPTIONS = {
39
39
  preset: "centered",
40
40
  theme: "dark",
@@ -62,50 +62,80 @@ var DEFAULT_FONT_FAMILY = "WelcomeCard";
62
62
  // src/presets/centered.ts
63
63
  var EDGE_PAD = 24;
64
64
  var TOP_BASELINE = 36;
65
+ function isHexBackground(background) {
66
+ return typeof background === "string" && /^#[0-9a-fA-F]{3,8}$/.test(background);
67
+ }
68
+ function resolveBackgroundColor(background, fallback) {
69
+ return isHexBackground(background) ? background : fallback;
70
+ }
65
71
  function resolvePosition(position, width, height) {
66
72
  const pos = position === void 0 || position === "corner" ? "bottom-right" : position;
67
- const [v, h] = pos === "center" ? ["center", "center"] : pos.split("-");
68
- return {
69
- x: h === "left" ? EDGE_PAD : h === "center" ? width / 2 : width - EDGE_PAD,
70
- y: v === "top" ? TOP_BASELINE : v === "center" ? height / 2 : height - EDGE_PAD,
71
- align: h
72
- };
73
+ const x = { left: EDGE_PAD, center: width / 2, right: width - EDGE_PAD };
74
+ const y = { top: TOP_BASELINE, center: height / 2, bottom: height - EDGE_PAD };
75
+ switch (pos) {
76
+ case "top-left":
77
+ return { x: x.left, y: y.top, align: "left" };
78
+ case "top-center":
79
+ return { x: x.center, y: y.top, align: "center" };
80
+ case "top-right":
81
+ return { x: x.right, y: y.top, align: "right" };
82
+ case "center-left":
83
+ return { x: x.left, y: y.center, align: "left" };
84
+ case "center":
85
+ return { x: x.center, y: y.center, align: "center" };
86
+ case "center-right":
87
+ return { x: x.right, y: y.center, align: "right" };
88
+ case "bottom-left":
89
+ return { x: x.left, y: y.bottom, align: "left" };
90
+ case "bottom-center":
91
+ return { x: x.center, y: y.bottom, align: "center" };
92
+ case "bottom-right":
93
+ return { x: x.right, y: y.bottom, align: "right" };
94
+ default: {
95
+ const _exhausted = pos;
96
+ throw new Error(`Unhandled memberCountPosition: "${_exhausted}"`);
97
+ }
98
+ }
73
99
  }
74
100
  function centeredLayout(opts) {
75
101
  const width = CARD_WIDTH;
76
102
  const height = CARD_HEIGHT;
77
103
  const accent = opts.theme === "dark" ? "#89b4fa" : "#1e66f5";
78
- const bgColor = typeof opts.background === "string" && opts.background.startsWith("#") ? opts.background : opts.theme === "dark" ? "#1e1e2e" : "#eff1f5";
104
+ const bgColor = resolveBackgroundColor(
105
+ opts.background,
106
+ opts.theme === "dark" ? "#1e1e2e" : "#eff1f5"
107
+ );
79
108
  const textColor = opts.font.color ?? (opts.theme === "dark" ? "#ffffff" : "#11111b");
109
+ const usernameColor = opts.font.usernameColor ?? textColor;
80
110
  const family = opts.font.family ?? DEFAULT_FONT_FAMILY;
81
- const avatarSize = 120;
82
- const avatarY = 50;
111
+ const avatarSize = 170;
112
+ const avatarY = 56;
83
113
  return {
84
114
  width,
85
115
  height,
86
116
  backgroundColor: bgColor,
87
- background: typeof opts.background === "string" && opts.background.startsWith("#") ? void 0 : opts.background,
117
+ background: isHexBackground(opts.background) ? void 0 : opts.background,
88
118
  avatar: {
89
119
  x: width / 2 - avatarSize / 2,
90
120
  y: avatarY,
91
121
  size: avatarSize,
92
- ringColor: accent,
122
+ ringColor: opts.ringColor ?? accent,
93
123
  source: opts.avatar
94
124
  },
95
125
  username: {
96
126
  text: opts.username ?? "",
97
127
  x: width / 2,
98
- y: avatarY + avatarSize + 60,
99
- size: opts.font.size ?? 44,
100
- color: textColor,
128
+ y: avatarY + avatarSize + 66,
129
+ size: opts.font.size ?? 56,
130
+ color: usernameColor,
101
131
  family,
102
132
  align: "center"
103
133
  },
104
134
  subtitle: opts.subtitle ? {
105
135
  text: opts.subtitle,
106
136
  x: width / 2,
107
- y: avatarY + avatarSize + 105,
108
- size: 24,
137
+ y: avatarY + avatarSize + 122,
138
+ size: opts.font.subtitleSize ?? Math.round((opts.font.size ?? 56) * 0.61),
109
139
  color: textColor,
110
140
  family,
111
141
  align: "center"
@@ -114,7 +144,7 @@ function centeredLayout(opts) {
114
144
  text: `MEMBER #${opts.memberCount}`,
115
145
  // centered preset: everything centered by default, count included
116
146
  ...resolvePosition(opts.memberCountPosition ?? "bottom-center", width, height),
117
- size: 16,
147
+ size: 20,
118
148
  color: textColor,
119
149
  family
120
150
  } : void 0
@@ -126,7 +156,7 @@ function neonLayout(opts) {
126
156
  const base = centeredLayout(opts);
127
157
  return {
128
158
  ...base,
129
- backgroundColor: opts.background?.startsWith("#") ? opts.background : "#0b0b1a",
159
+ backgroundColor: resolveBackgroundColor(opts.background, "#0b0b1a"),
130
160
  avatar: { ...base.avatar, ringColor: "#00ffd5" },
131
161
  username: { ...base.username, color: opts.font.color ?? "#00ffd5" }
132
162
  };
@@ -137,7 +167,7 @@ function minimalLayout(opts) {
137
167
  const base = centeredLayout(opts);
138
168
  return {
139
169
  ...base,
140
- backgroundColor: opts.background?.startsWith("#") ? opts.background : "#ffffff",
170
+ backgroundColor: resolveBackgroundColor(opts.background, "#ffffff"),
141
171
  avatar: { ...base.avatar, ringColor: "#222222" },
142
172
  username: { ...base.username, color: opts.font.color ?? "#111111" },
143
173
  subtitle: base.subtitle ? { ...base.subtitle, color: "#555555" } : void 0
@@ -151,10 +181,13 @@ function heroLayout(opts) {
151
181
  const textX = avatarX + base.avatar.size + 40;
152
182
  return {
153
183
  ...base,
154
- backgroundColor: opts.background?.startsWith("#") ? opts.background : "#101830",
184
+ backgroundColor: resolveBackgroundColor(
185
+ opts.background,
186
+ opts.theme === "dark" ? "#1b1f2a" : "#e8ecf4"
187
+ ),
155
188
  avatar: { ...base.avatar, x: avatarX, y: base.height / 2 - base.avatar.size / 2 },
156
- username: { ...base.username, x: textX, y: base.height / 2 - 6, align: "left" },
157
- subtitle: base.subtitle ? { ...base.subtitle, x: textX, y: base.height / 2 + 30, align: "left" } : void 0,
189
+ username: { ...base.username, x: textX, y: base.height / 2 - 12, align: "left" },
190
+ subtitle: base.subtitle ? { ...base.subtitle, x: textX, y: base.height / 2 + 42, align: "left" } : void 0,
158
191
  memberCount: base.memberCount ? (
159
192
  // hero is asymmetric — default the count to the corner, not bottom-center
160
193
  {
@@ -197,37 +230,109 @@ function layout(opts) {
197
230
 
198
231
  // src/assets/loadImage.ts
199
232
  var import_canvas2 = require("@napi-rs/canvas");
233
+ var MAX_CACHE = 256;
234
+ function boundedSet(map, key, value) {
235
+ if (map.size >= MAX_CACHE) {
236
+ map.delete(map.keys().next().value);
237
+ }
238
+ map.set(key, value);
239
+ }
240
+ var cache = /* @__PURE__ */ new Map();
241
+ var solidCache = /* @__PURE__ */ new Map();
200
242
  async function solidImage(color, w, h) {
243
+ const key = `${color}:${w}x${h}`;
244
+ const hit = solidCache.get(key);
245
+ if (hit) return hit;
201
246
  const canvas = (0, import_canvas2.createCanvas)(w, h);
202
247
  const ctx = canvas.getContext("2d");
203
248
  ctx.fillStyle = color;
204
249
  ctx.fillRect(0, 0, w, h);
205
- return (0, import_canvas2.loadImage)(canvas.toBuffer("image/png"));
250
+ const img = await (0, import_canvas2.loadImage)(canvas.toBuffer("image/png"));
251
+ boundedSet(solidCache, key, img);
252
+ return img;
206
253
  }
207
- var cache = /* @__PURE__ */ new Map();
208
254
  async function loadImageOrFallback(source, fallbackColor, w = 256, h = 256) {
209
255
  if (source === void 0) return solidImage(fallbackColor, w, h);
210
256
  const hit = cache.get(source);
211
257
  if (hit) return hit;
212
258
  try {
213
259
  const img = await (0, import_canvas2.loadImage)(source);
214
- cache.set(source, img);
260
+ boundedSet(cache, source, img);
215
261
  return img;
216
262
  } catch {
217
263
  return solidImage(fallbackColor, w, h);
218
264
  }
219
265
  }
220
266
 
267
+ // src/assets/colorUtils.ts
268
+ function hexToHsl(hex) {
269
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
270
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
271
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
272
+ const max = Math.max(r, g, b);
273
+ const min = Math.min(r, g, b);
274
+ const l = (max + min) / 2;
275
+ if (max === min) return [0, 0, l];
276
+ const d = max - min;
277
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
278
+ let h;
279
+ switch (max) {
280
+ case r:
281
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
282
+ break;
283
+ case g:
284
+ h = ((b - r) / d + 2) / 6;
285
+ break;
286
+ default:
287
+ h = ((r - g) / d + 4) / 6;
288
+ }
289
+ return [h, s, l];
290
+ }
291
+ function hslToHex(h, s, l) {
292
+ const hue2rgb = (p, q2, t) => {
293
+ if (t < 0) t += 1;
294
+ if (t > 1) t -= 1;
295
+ if (t < 1 / 6) return p + (q2 - p) * 6 * t;
296
+ if (t < 1 / 2) return q2;
297
+ if (t < 2 / 3) return p + (q2 - p) * (2 / 3 - t) * 6;
298
+ return p;
299
+ };
300
+ let r, g, b;
301
+ if (s === 0) {
302
+ r = g = b = l;
303
+ } else {
304
+ const q2 = l < 0.5 ? l * (1 + s) : l + s - l * s;
305
+ const p = 2 * l - q2;
306
+ r = hue2rgb(p, q2, h + 1 / 3);
307
+ g = hue2rgb(p, q2, h);
308
+ b = hue2rgb(p, q2, h - 1 / 3);
309
+ }
310
+ const hex = (x) => Math.round(x * 255).toString(16).padStart(2, "0");
311
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
312
+ }
313
+ function rotateHue(hexColor, amount) {
314
+ const [h, s, l] = hexToHsl(hexColor);
315
+ return hslToHex((h + amount + 1) % 1, s, l);
316
+ }
317
+
221
318
  // src/render/drawFrame.ts
222
- var STATIC_FRAME = { textAlpha: 1, avatarGlow: 1, backgroundShift: 0 };
223
- function drawText(ctx, box, alpha) {
319
+ var STATIC_FRAME = {
320
+ textAlpha: 1,
321
+ avatarGlow: 1,
322
+ backgroundShift: 0,
323
+ ringShift: 0,
324
+ textSlide: 1,
325
+ avatarBounce: 0
326
+ };
327
+ var BOUNCE_AMPLITUDE = 10;
328
+ function drawText(ctx, box, alpha, slideOffset) {
224
329
  ctx.save();
225
330
  ctx.globalAlpha = alpha;
226
331
  ctx.fillStyle = box.color;
227
332
  ctx.textAlign = box.align;
228
333
  ctx.textBaseline = "alphabetic";
229
334
  ctx.font = `${box.size}px ${box.family}`;
230
- ctx.fillText(box.text, box.x, box.y);
335
+ ctx.fillText(box.text, box.x, box.y + slideOffset);
231
336
  ctx.restore();
232
337
  }
233
338
  async function drawFrame(ctx, l, state) {
@@ -247,13 +352,15 @@ async function drawFrame(ctx, l, state) {
247
352
  ctx.fillRect(0, 0, l.width, l.height);
248
353
  }
249
354
  const { x, y, size, ringColor } = l.avatar;
355
+ const animatedRingColor = state.ringShift > 0 ? rotateHue(ringColor, state.ringShift) : ringColor;
356
+ const bounceOffset = state.avatarBounce * BOUNCE_AMPLITUDE;
250
357
  const cx = x + size / 2;
251
- const cy = y + size / 2;
358
+ const cy = y + size / 2 + bounceOffset;
252
359
  ctx.save();
253
- ctx.shadowColor = ringColor;
360
+ ctx.shadowColor = animatedRingColor;
254
361
  ctx.shadowBlur = 10 + state.avatarGlow * 25;
255
362
  ctx.lineWidth = 6;
256
- ctx.strokeStyle = ringColor;
363
+ ctx.strokeStyle = animatedRingColor;
257
364
  ctx.beginPath();
258
365
  ctx.arc(cx, cy, size / 2 + 3, 0, Math.PI * 2);
259
366
  ctx.stroke();
@@ -263,11 +370,12 @@ async function drawFrame(ctx, l, state) {
263
370
  ctx.beginPath();
264
371
  ctx.arc(cx, cy, size / 2, 0, Math.PI * 2);
265
372
  ctx.clip();
266
- ctx.drawImage(avatarImg, x, y, size, size);
373
+ ctx.drawImage(avatarImg, x, y + bounceOffset, size, size);
267
374
  ctx.restore();
268
- drawText(ctx, l.username, state.textAlpha);
269
- if (l.subtitle) drawText(ctx, l.subtitle, state.textAlpha);
270
- if (l.memberCount) drawText(ctx, l.memberCount, state.textAlpha);
375
+ const slideOffset = (1 - state.textSlide) * l.height;
376
+ drawText(ctx, l.username, state.textAlpha, slideOffset);
377
+ if (l.subtitle) drawText(ctx, l.subtitle, state.textAlpha, slideOffset);
378
+ if (l.memberCount) drawText(ctx, l.memberCount, state.textAlpha, slideOffset);
271
379
  }
272
380
 
273
381
  // src/render/toPNG.ts
@@ -646,12 +754,31 @@ function backgroundShift(progress) {
646
754
  return progress;
647
755
  }
648
756
 
757
+ // src/animate/ring.ts
758
+ function ringShift(progress) {
759
+ return progress;
760
+ }
761
+
762
+ // src/animate/slide.ts
763
+ function textSlide(progress) {
764
+ const p = Math.min(progress / 0.8, 1);
765
+ return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
766
+ }
767
+
768
+ // src/animate/bounce.ts
769
+ function avatarBounce(progress) {
770
+ return Math.sin(progress * Math.PI * 2);
771
+ }
772
+
649
773
  // src/animate/index.ts
650
774
  function frameStateFor(animations, progress) {
651
775
  return {
652
776
  textAlpha: animations.includes("text") ? textAlpha(progress) : 1,
653
777
  avatarGlow: animations.includes("avatar") ? avatarGlow(progress) : 1,
654
- backgroundShift: animations.includes("background") ? backgroundShift(progress) : 0
778
+ backgroundShift: animations.includes("background") ? backgroundShift(progress) : 0,
779
+ ringShift: animations.includes("ring") ? ringShift(progress) : 0,
780
+ textSlide: animations.includes("slide") ? textSlide(progress) : 1,
781
+ avatarBounce: animations.includes("bounce") ? avatarBounce(progress) : 0
655
782
  };
656
783
  }
657
784
 
@@ -668,6 +795,12 @@ async function renderGIF(opts) {
668
795
  const enc = GIFEncoder();
669
796
  const frames = opts.animations.length === 0 ? 1 : GIF_FRAMES;
670
797
  let palette;
798
+ if (frames > 1) {
799
+ const paletteState = frameStateFor(opts.animations, 0.5);
800
+ await drawFrame(ctx, l, paletteState);
801
+ outCtx.drawImage(canvas, 0, 0, gw, gh);
802
+ palette = quantize(outCtx.getImageData(0, 0, gw, gh).data, 256);
803
+ }
671
804
  for (let i = 0; i < frames; i++) {
672
805
  const progress = frames === 1 ? 0 : i / frames;
673
806
  const state = frameStateFor(opts.animations, progress);
@@ -684,7 +817,7 @@ async function renderGIF(opts) {
684
817
 
685
818
  // src/WelcomeCard.ts
686
819
  var WelcomeCard = class {
687
- opts = { ...DEFAULT_OPTIONS, font: {}, animations: [] };
820
+ opts = { ...DEFAULT_OPTIONS };
688
821
  setPreset(preset) {
689
822
  if (!PRESETS[preset]) {
690
823
  throw new WelcomeCardError(
@@ -718,6 +851,10 @@ var WelcomeCard = class {
718
851
  this.opts.background = background;
719
852
  return this;
720
853
  }
854
+ setRingColor(color) {
855
+ this.opts.ringColor = color;
856
+ return this;
857
+ }
721
858
  setTheme(theme) {
722
859
  this.opts.theme = theme;
723
860
  return this;