diff --git a/bskyogcard/package.json b/bskyogcard/package.json
index e9fa78c8ee..55c6c6dba5 100644
--- a/bskyogcard/package.json
+++ b/bskyogcard/package.json
@@ -3,14 +3,11 @@
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
- "engines": {
- "node": ">=24.15.0"
- },
"scripts": {
"start": "tsx ./src/bin.ts",
- "dev": "tsx watch --clear-screen=false ./src/bin.ts",
+ "dev": "node --watch-path ./src --import tsx ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
- "install-fonts": "node scripts/install-fonts.ts"
+ "install-fonts": "tsx scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
@@ -28,7 +25,7 @@
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^24.12.2",
- "@types/react": "^18.3.0",
+ "@types/react": "^18.3.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
diff --git a/bskyogcard/src/bin.ts b/bskyogcard/src/bin.ts
index ff550809db..b7bf261817 100644
--- a/bskyogcard/src/bin.ts
+++ b/bskyogcard/src/bin.ts
@@ -1,4 +1,4 @@
-import cluster, {Worker} from 'node:cluster'
+import cluster, {type Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
diff --git a/bskyogcard/src/components/AvatarBubbles.tsx b/bskyogcard/src/components/AvatarBubbles.tsx
new file mode 100644
index 0000000000..ef7f2c1e89
--- /dev/null
+++ b/bskyogcard/src/components/AvatarBubbles.tsx
@@ -0,0 +1,121 @@
+import {Img} from './Img.js'
+
+const SCALE = 3
+const BASE_SIZE = 120
+const PADDING = 4
+const CONTENT_SCALE = (BASE_SIZE - PADDING * 2) / BASE_SIZE
+
+export const AVATAR_BUBBLES_SIZE = BASE_SIZE * SCALE
+
+type BubbleConfig = {size: number; x: number; y: number}
+
+const LAYOUTS: Record<1 | 2 | 3 | 4, BubbleConfig[]> = {
+ 1: [
+ {size: 76, x: 0, y: 0},
+ {size: 76, x: 44, y: 44},
+ ],
+ 2: [
+ {size: 76, x: 0, y: 0},
+ {size: 76, x: 44, y: 44},
+ ],
+ 3: [
+ {size: 68, x: 0.5, y: 0},
+ {size: 56, x: 40.5, y: 64},
+ {size: 46, x: 73.5, y: 20},
+ ],
+ 4: [
+ {size: 68, x: 1, y: 1},
+ {size: 56, x: 63, y: 52},
+ {size: 42, x: 17, y: 77},
+ {size: 32, x: 75, y: 12},
+ ],
+}
+
+const PERSON_PATH =
+ 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z'
+
+export function AvatarBubbles(props: {images: (Buffer | null)[]}) {
+ const count = Math.min(props.images.length, 4) as 1 | 2 | 3 | 4
+ const layout = LAYOUTS[count] ?? LAYOUTS[2]
+
+ const bubbles = layout.map((bubble, i) => ({
+ ...bubble,
+ image: props.images[i] ?? null,
+ }))
+
+ // For the 2-avi layout, render in reverse so the first (top-left) paints last (on top)
+ if (count <= 2) {
+ bubbles.reverse()
+ }
+
+ return (
+
+ {bubbles.map((bubble, i) => (
+
+ ))}
+
+ )
+}
+
+function AvatarBubble(props: {
+ image: Buffer | null
+ size: number
+ x: number
+ y: number
+}) {
+ return (
+
+ {props.image ? (
+

+ ) : (
+
+ )}
+
+ )
+}
+
+function Placeholder(props: {size: number}) {
+ const iconSize = props.size * 0.5
+ const offset = (props.size - iconSize) / 2
+ return (
+
+ )
+}
diff --git a/bskyogcard/src/components/Butterfly.tsx b/bskyogcard/src/components/Butterfly.tsx
index 5a4124975c..f84e816d1e 100644
--- a/bskyogcard/src/components/Butterfly.tsx
+++ b/bskyogcard/src/components/Butterfly.tsx
@@ -1,5 +1,3 @@
-import React from 'react'
-
export function Butterfly(props: React.SVGAttributes) {
return (