@quitscope/discord-welcomecard 0.2.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
@@ -34,7 +34,7 @@ var CARD_WIDTH = 840;
34
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",
@@ -106,6 +106,7 @@ function centeredLayout(opts) {
106
106
  opts.theme === "dark" ? "#1e1e2e" : "#eff1f5"
107
107
  );
108
108
  const textColor = opts.font.color ?? (opts.theme === "dark" ? "#ffffff" : "#11111b");
109
+ const usernameColor = opts.font.usernameColor ?? textColor;
109
110
  const family = opts.font.family ?? DEFAULT_FONT_FAMILY;
110
111
  const avatarSize = 170;
111
112
  const avatarY = 56;
@@ -118,7 +119,7 @@ function centeredLayout(opts) {
118
119
  x: width / 2 - avatarSize / 2,
119
120
  y: avatarY,
120
121
  size: avatarSize,
121
- ringColor: accent,
122
+ ringColor: opts.ringColor ?? accent,
122
123
  source: opts.avatar
123
124
  },
124
125
  username: {
@@ -126,7 +127,7 @@ function centeredLayout(opts) {
126
127
  x: width / 2,
127
128
  y: avatarY + avatarSize + 66,
128
129
  size: opts.font.size ?? 56,
129
- color: textColor,
130
+ color: usernameColor,
130
131
  family,
131
132
  align: "center"
132
133
  },
@@ -263,16 +264,75 @@ async function loadImageOrFallback(source, fallbackColor, w = 256, h = 256) {
263
264
  }
264
265
  }
265
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
+
266
318
  // src/render/drawFrame.ts
267
- var STATIC_FRAME = { textAlpha: 1, avatarGlow: 1, backgroundShift: 0 };
268
- 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) {
269
329
  ctx.save();
270
330
  ctx.globalAlpha = alpha;
271
331
  ctx.fillStyle = box.color;
272
332
  ctx.textAlign = box.align;
273
333
  ctx.textBaseline = "alphabetic";
274
334
  ctx.font = `${box.size}px ${box.family}`;
275
- ctx.fillText(box.text, box.x, box.y);
335
+ ctx.fillText(box.text, box.x, box.y + slideOffset);
276
336
  ctx.restore();
277
337
  }
278
338
  async function drawFrame(ctx, l, state) {
@@ -292,13 +352,15 @@ async function drawFrame(ctx, l, state) {
292
352
  ctx.fillRect(0, 0, l.width, l.height);
293
353
  }
294
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;
295
357
  const cx = x + size / 2;
296
- const cy = y + size / 2;
358
+ const cy = y + size / 2 + bounceOffset;
297
359
  ctx.save();
298
- ctx.shadowColor = ringColor;
360
+ ctx.shadowColor = animatedRingColor;
299
361
  ctx.shadowBlur = 10 + state.avatarGlow * 25;
300
362
  ctx.lineWidth = 6;
301
- ctx.strokeStyle = ringColor;
363
+ ctx.strokeStyle = animatedRingColor;
302
364
  ctx.beginPath();
303
365
  ctx.arc(cx, cy, size / 2 + 3, 0, Math.PI * 2);
304
366
  ctx.stroke();
@@ -308,11 +370,12 @@ async function drawFrame(ctx, l, state) {
308
370
  ctx.beginPath();
309
371
  ctx.arc(cx, cy, size / 2, 0, Math.PI * 2);
310
372
  ctx.clip();
311
- ctx.drawImage(avatarImg, x, y, size, size);
373
+ ctx.drawImage(avatarImg, x, y + bounceOffset, size, size);
312
374
  ctx.restore();
313
- drawText(ctx, l.username, state.textAlpha);
314
- if (l.subtitle) drawText(ctx, l.subtitle, state.textAlpha);
315
- 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);
316
379
  }
317
380
 
318
381
  // src/render/toPNG.ts
@@ -691,12 +754,31 @@ function backgroundShift(progress) {
691
754
  return progress;
692
755
  }
693
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
+
694
773
  // src/animate/index.ts
695
774
  function frameStateFor(animations, progress) {
696
775
  return {
697
776
  textAlpha: animations.includes("text") ? textAlpha(progress) : 1,
698
777
  avatarGlow: animations.includes("avatar") ? avatarGlow(progress) : 1,
699
- 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
700
782
  };
701
783
  }
702
784
 
@@ -769,6 +851,10 @@ var WelcomeCard = class {
769
851
  this.opts.background = background;
770
852
  return this;
771
853
  }
854
+ setRingColor(color) {
855
+ this.opts.ringColor = color;
856
+ return this;
857
+ }
772
858
  setTheme(theme) {
773
859
  this.opts.theme = theme;
774
860
  return this;