Skip to content

Commit 1639b9a

Browse files
Brooooooklynclaude
andauthored
feat(sponsors-image): center the card, drop header, ring + link the logos (#485)
- Center-align the whole card: the "NAPI-RS Sponsors" header/subtitle/divider are removed and the tiers, labels, and logo rows are all centered. Tier labels are flanked by short rules; roomier vertical rhythm. - Add a faint per-theme `ring` token drawn around every avatar so a logo that matches the background (a black circle on dark, a white mark on light) still reads as present instead of vanishing. - Wrap each avatar in an <a> to the sponsor's page. satori strips <a> (it targets static images), so linkAvatars pairs the Nth rendered <image> with the Nth sponsor (order- and count-safe). Sponsors are clickable when the .svg is opened directly; README `![]()` embeds render as flat images as before. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e8e27b8 commit 1639b9a

4 files changed

Lines changed: 128 additions & 49 deletions

File tree

lib/sponsors-image/card.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,39 @@ describe('renderSvg', () => {
4646
expect((svg.match(/<image/g) ?? []).length).toBe(3)
4747
})
4848

49-
it('renders header-only svg (no <image>) when there are no sponsors', async () => {
49+
it('renders an svg with no <image> when there are no sponsors', async () => {
5050
const svg = await renderSvg(empty(), 'dark', fonts())
5151
expect(svg.startsWith('<svg')).toBe(true)
5252
expect(svg.includes('<image')).toBe(false)
5353
})
54+
55+
it('wraps each avatar in an <a> link to its sponsor page', async () => {
56+
const sponsors: WashedSponsors = {
57+
...empty(),
58+
gold: [
59+
{ name: 'A', img: dot, url: 'https://github.com/a' },
60+
{ name: 'B', img: dot, url: 'https://github.com/b' },
61+
],
62+
}
63+
const svg = await renderSvg(sponsors, 'light', fonts())
64+
const anchors = svg.match(/<a href="[^"]*"/g) ?? []
65+
const images = svg.match(/<image/g) ?? []
66+
// One clickable link per avatar, in sponsor order.
67+
expect(images.length).toBe(2)
68+
expect(anchors.length).toBe(images.length)
69+
expect(svg).toContain(
70+
'<a href="https://github.com/a" target="_blank" rel="noopener"><image',
71+
)
72+
expect(svg).toContain('href="https://github.com/b"')
73+
})
74+
75+
it('escapes special characters in a link href', async () => {
76+
const sponsors: WashedSponsors = {
77+
...empty(),
78+
gold: [{ name: 'A', img: dot, url: 'https://github.com/a&b' }],
79+
}
80+
const svg = await renderSvg(sponsors, 'light', fonts())
81+
expect(svg).toContain('href="https://github.com/a&amp;b"')
82+
expect(svg).not.toContain('href="https://github.com/a&b"')
83+
})
5484
})

lib/sponsors-image/card.ts

Lines changed: 85 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function ensureYoga(wasm: WebAssembly.Module): Promise<void> {
5151
// satori node helpers (avoids a JSX runtime; matches the spike-verified shape).
5252
type Node = { type: string; props: Record<string, unknown> }
5353

54-
function avatar(sponsor: WashedSponsor, size: number): Node {
54+
function avatar(sponsor: WashedSponsor, size: number, t: ThemeTokens): Node {
5555
return {
5656
type: 'img',
5757
props: {
@@ -62,102 +62,134 @@ function avatar(sponsor: WashedSponsor, size: number): Node {
6262
width: size,
6363
height: size,
6464
borderRadius: size / 2,
65-
marginRight: 8,
66-
marginBottom: 8,
65+
// Faint ring so a logo matching the background still reads as present.
66+
border: `1px solid ${t.ring}`,
67+
// Symmetric horizontal margins so a centred, wrapping row stays centred.
68+
marginLeft: 6,
69+
marginRight: 6,
70+
marginBottom: 12,
6771
},
6872
},
6973
}
7074
}
7175

72-
function tierSection(
73-
spec: TierSpec,
74-
list: WashedSponsor[],
75-
t: ThemeTokens,
76-
): Node {
76+
function escapeXmlAttr(value: string): string {
77+
return value
78+
.replace(/&/g, '&amp;')
79+
.replace(/</g, '&lt;')
80+
.replace(/>/g, '&gt;')
81+
.replace(/"/g, '&quot;')
82+
}
83+
84+
// satori strips <a> wrappers (it targets static images), so we wrap the rendered
85+
// avatars ourselves: each self-closing <image> is one avatar, emitted in the same
86+
// order as `urls`, so wrap the Nth in an SVG <a> to the Nth sponsor's page. Makes
87+
// sponsors clickable when the .svg is opened directly; README `![]()` embeds still
88+
// render as a flat image (anchors are ignored there).
89+
function linkAvatars(svg: string, urls: string[]): string {
90+
let i = 0
91+
return svg.replace(/<image[^>]*\/>/g, (image) => {
92+
const url = urls[i++]
93+
return url
94+
? `<a href="${escapeXmlAttr(url)}" target="_blank" rel="noopener">${image}</a>`
95+
: image
96+
})
97+
}
98+
99+
// A centred tier label flanked by two short rules — "── SPECIAL THANKS ──".
100+
function tierLabel(label: string, t: ThemeTokens): Node {
101+
const rule = (side: 'left' | 'right'): Node => ({
102+
type: 'div',
103+
props: {
104+
style: {
105+
display: 'flex',
106+
width: 24,
107+
height: 1,
108+
background: t.border,
109+
[side === 'left' ? 'marginRight' : 'marginLeft']: 14,
110+
},
111+
},
112+
})
77113
return {
78114
type: 'div',
79115
props: {
80-
style: { display: 'flex', flexDirection: 'column', marginTop: 20 },
116+
style: {
117+
display: 'flex',
118+
alignItems: 'center',
119+
justifyContent: 'center',
120+
marginBottom: 18,
121+
},
81122
children: [
123+
rule('left'),
82124
{
83125
type: 'div',
84126
props: {
85127
style: {
86128
display: 'flex',
87129
color: t.muted,
88130
fontSize: 13,
89-
fontWeight: 500,
90-
letterSpacing: 1,
91-
marginBottom: 10,
131+
fontWeight: 600,
132+
letterSpacing: 2.5,
92133
},
93-
children: spec.label,
94-
},
95-
},
96-
{
97-
type: 'div',
98-
props: {
99-
style: { display: 'flex', flexWrap: 'wrap' },
100-
children: list.map((s) => avatar(s, spec.size)),
134+
children: label,
101135
},
102136
},
137+
rule('right'),
103138
],
104139
},
105140
}
106141
}
107142

108-
function header(t: ThemeTokens): Node {
143+
function tierSection(
144+
spec: TierSpec,
145+
list: WashedSponsor[],
146+
t: ThemeTokens,
147+
first: boolean,
148+
): Node {
109149
return {
110150
type: 'div',
111151
props: {
112152
style: {
113153
display: 'flex',
114154
flexDirection: 'column',
115-
borderBottom: `1px solid ${t.border}`,
116-
paddingBottom: 16,
155+
alignItems: 'center',
156+
width: '100%',
157+
marginTop: first ? 0 : 40,
117158
},
118159
children: [
160+
tierLabel(spec.label, t),
119161
{
120162
type: 'div',
121163
props: {
122164
style: {
123165
display: 'flex',
124-
color: t.fg,
125-
fontSize: 30,
126-
fontWeight: 700,
127-
},
128-
children: 'NAPI-RS Sponsors',
129-
},
130-
},
131-
{
132-
type: 'div',
133-
props: {
134-
style: {
135-
display: 'flex',
136-
color: t.accent,
137-
fontSize: 15,
138-
fontWeight: 500,
139-
marginTop: 4,
166+
flexWrap: 'wrap',
167+
justifyContent: 'center',
168+
alignItems: 'center',
140169
},
141-
children: 'napi.rs',
170+
children: list.map((s) => avatar(s, spec.size, t)),
142171
},
143172
},
144173
],
145174
},
146175
}
147176
}
148177

149-
export function renderSvg(
178+
export async function renderSvg(
150179
sponsors: WashedSponsors,
151180
theme: Theme,
152181
fonts: SatoriFont[],
153182
): Promise<string> {
154183
const t = THEMES[theme]
155-
const sections = TIER_SPECS.map((spec) => ({
184+
const rendered = TIER_SPECS.map((spec) => ({
156185
spec,
157186
list: sponsors[spec.key],
158-
}))
159-
.filter(({ list }) => list.length > 0)
160-
.map(({ spec, list }) => tierSection(spec, list, t))
187+
})).filter(({ list }) => list.length > 0)
188+
const sections = rendered.map(({ spec, list }, i) =>
189+
tierSection(spec, list, t, i === 0),
190+
)
191+
// Same order the avatars are emitted in, so linkAvatars can pair them 1:1.
192+
const urls = rendered.flatMap(({ list }) => list.map((s) => s.url))
161193

162194
const root: Node = {
163195
type: 'div',
@@ -166,17 +198,22 @@ export function renderSvg(
166198
width: '100%',
167199
display: 'flex',
168200
flexDirection: 'column',
201+
alignItems: 'center',
169202
background: t.bg,
170-
padding: 40,
203+
paddingTop: 48,
204+
paddingBottom: 52,
205+
paddingLeft: 48,
206+
paddingRight: 48,
171207
fontFamily: 'Manrope',
172208
},
173-
children: [header(t), ...sections],
209+
children: sections,
174210
},
175211
}
176212

177213
// satori accepts plain {type,props} nodes as ReactNode; cast keeps TS happy.
178-
return satori(root as unknown as Parameters<typeof satori>[0], {
214+
const svg = await satori(root as unknown as Parameters<typeof satori>[0], {
179215
width: CARD_WIDTH,
180216
fonts,
181217
})
218+
return linkAvatars(svg, urls)
182219
}

lib/sponsors-image/theme.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ describe('THEMES', () => {
2323
}
2424
expect(THEMES.light.bg).not.toBe(THEMES.dark.bg)
2525
})
26+
it('has a non-empty ring token per theme (avatar outline)', () => {
27+
for (const theme of ['light', 'dark'] as const) {
28+
expect(typeof THEMES[theme].ring).toBe('string')
29+
expect(THEMES[theme].ring.length).toBeGreaterThan(0)
30+
}
31+
})
2632
})

lib/sponsors-image/theme.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export interface ThemeTokens {
1010
muted: string
1111
accent: string
1212
border: string
13+
// Faint outline drawn around every avatar so a logo that matches the
14+
// background (a black circle on dark, a white mark on light) still reads as
15+
// present instead of vanishing. Light outline on dark, dark outline on light.
16+
ring: string
1317
}
1418

1519
export const THEMES: Record<Theme, ThemeTokens> = {
@@ -19,13 +23,15 @@ export const THEMES: Record<Theme, ThemeTokens> = {
1923
muted: '#64748b',
2024
accent: '#e66000',
2125
border: '#e2e8f0',
26+
ring: 'rgba(15, 23, 42, 0.12)',
2227
},
2328
dark: {
2429
bg: '#0b0d10',
2530
fg: '#f8fafc',
2631
muted: '#94a3b8',
2732
accent: '#f97316',
2833
border: '#1f2937',
34+
ring: 'rgba(248, 250, 252, 0.24)',
2935
},
3036
}
3137

0 commit comments

Comments
 (0)