Skip to content

Commit 6811691

Browse files
committed
fix: prevent OG text bleed
1 parent 26b46d9 commit 6811691

File tree

4 files changed

+82
-33
lines changed

4 files changed

+82
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
### Fixed
99
- Web: stabilize skill OG image generation on server runtimes.
10+
- Web: prevent skill OG text overflow outside the card.
1011

1112
## 0.1.0 - 2026-01-07
1213

server/og/skillOgSvg.ts

Lines changed: 79 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,51 @@ function wrapText(value: string, maxChars: number, maxLines: number) {
2222
const words = value.trim().split(/\s+/).filter(Boolean)
2323
const lines: string[] = []
2424
let current = ''
25+
26+
function pushLine(line: string) {
27+
if (!line) return
28+
lines.push(line)
29+
}
30+
31+
function splitLongWord(word: string) {
32+
if (word.length <= maxChars) return [word]
33+
const parts: string[] = []
34+
let remaining = word
35+
while (remaining.length > maxChars) {
36+
parts.push(`${remaining.slice(0, maxChars - 1)}…`)
37+
remaining = remaining.slice(maxChars - 1)
38+
}
39+
if (remaining) parts.push(remaining)
40+
return parts
41+
}
42+
2543
for (const word of words) {
44+
if (word.length > maxChars) {
45+
if (current) {
46+
pushLine(current)
47+
current = ''
48+
if (lines.length >= maxLines - 1) break
49+
}
50+
const parts = splitLongWord(word)
51+
for (const part of parts) {
52+
pushLine(part)
53+
if (lines.length >= maxLines) break
54+
}
55+
current = ''
56+
if (lines.length >= maxLines - 1) break
57+
continue
58+
}
59+
2660
const next = current ? `${current} ${word}` : word
2761
if (next.length <= maxChars) {
2862
current = next
2963
continue
3064
}
31-
if (current) lines.push(current)
65+
pushLine(current)
3266
current = word
3367
if (lines.length >= maxLines - 1) break
3468
}
35-
if (lines.length < maxLines && current) lines.push(current)
69+
if (lines.length < maxLines && current) pushLine(current)
3670
if (lines.length > maxLines) lines.length = maxLines
3771

3872
const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
@@ -48,6 +82,12 @@ export function buildSkillOgSvg(params: SkillOgSvgParams) {
4882
const rawTitle = params.title.trim() || 'ClawdHub Skill'
4983
const rawDescription = params.description.trim() || 'Published on ClawdHub.'
5084

85+
const cardX = 72
86+
const cardY = 96
87+
const cardW = 640
88+
const cardH = 456
89+
const cardR = 34
90+
5191
const titleLines = wrapText(rawTitle, 22, 2)
5292
const descLines = wrapText(rawDescription, 52, 3)
5393

@@ -59,6 +99,8 @@ export function buildSkillOgSvg(params: SkillOgSvgParams) {
5999
const descLineHeight = 34
60100

61101
const pillText = `${params.ownerLabel}${params.versionLabel}`
102+
const underlineY = cardY + cardH - 80
103+
const footerY = cardY + cardH - 18
62104

63105
const titleTspans = titleLines
64106
.map((line, index) => {
@@ -110,6 +152,10 @@ export function buildSkillOgSvg(params: SkillOgSvgParams) {
110152
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
111153
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
112154
</linearGradient>
155+
156+
<clipPath id="cardClip">
157+
<rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}"/>
158+
</clipPath>
113159
</defs>
114160
115161
<rect width="1200" height="630" fill="url(#bg)"/>
@@ -127,39 +173,41 @@ export function buildSkillOgSvg(params: SkillOgSvgParams) {
127173
</g>
128174
129175
<g filter="url(#cardShadow)">
130-
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
176+
<rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
131177
</g>
132178
133-
<image href="${params.markDataUrl}" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
179+
<g clip-path="url(#cardClip)">
180+
<image href="${params.markDataUrl}" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
134181
135-
<g>
136-
<rect x="166" y="136" width="520" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
137-
<text x="186" y="163"
182+
<g>
183+
<rect x="166" y="136" width="520" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
184+
<text x="186" y="163"
185+
fill="#F6EFE4"
186+
font-size="18"
187+
font-weight="600"
188+
font-family="${FONT_SANS}, sans-serif"
189+
opacity="0.92">${escapeXml(pillText)}</text>
190+
</g>
191+
192+
<text x="114" y="${titleY}"
193+
fill="#F6EFE4"
194+
font-size="${titleFontSize}"
195+
font-weight="800"
196+
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
197+
198+
<text x="114" y="${descY}"
199+
fill="#C6B8A8"
200+
font-size="26"
201+
font-weight="500"
202+
font-family="${FONT_SANS}, sans-serif">${descTspans}</text>
203+
204+
<rect x="114" y="${underlineY}" width="110" height="6" rx="3" fill="#E86A47"/>
205+
<text x="114" y="${footerY}"
138206
fill="#F6EFE4"
139-
font-size="18"
140-
font-weight="600"
141-
font-family="${FONT_SANS}, sans-serif"
142-
opacity="0.92">${escapeXml(pillText)}</text>
207+
font-size="20"
208+
font-weight="500"
209+
opacity="0.90"
210+
font-family="${FONT_MONO}, monospace">${escapeXml(params.footer)}</text>
143211
</g>
144-
145-
<text x="114" y="${titleY}"
146-
fill="#F6EFE4"
147-
font-size="${titleFontSize}"
148-
font-weight="800"
149-
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
150-
151-
<text x="114" y="${descY}"
152-
fill="#C6B8A8"
153-
font-size="26"
154-
font-weight="500"
155-
font-family="${FONT_SANS}, sans-serif">${descTspans}</text>
156-
157-
<rect x="114" y="472" width="110" height="6" rx="3" fill="#E86A47"/>
158-
<text x="114" y="530"
159-
fill="#F6EFE4"
160-
font-size="20"
161-
font-weight="500"
162-
opacity="0.90"
163-
font-family="${FONT_MONO}, monospace">${escapeXml(params.footer)}</text>
164212
</svg>`
165213
}

src/lib/og.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('og helpers', () => {
1919
expect(meta.url).toContain('/steipete/weather')
2020
expect(meta.owner).toBe('steipete')
2121
expect(meta.image).toContain('/og/skill.png?')
22-
expect(meta.image).toContain('v=2')
22+
expect(meta.image).toContain('v=3')
2323
expect(meta.image).toContain('slug=weather')
2424
expect(meta.image).toContain('owner=steipete')
2525
expect(meta.image).toContain('version=1.2.3')

src/lib/og.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type SkillMeta = {
1616

1717
const DEFAULT_SITE = 'https://clawdhub.com'
1818
const DEFAULT_DESCRIPTION = 'ClawdHub — a fast skill registry for agents, with vector search.'
19-
const OG_SKILL_IMAGE_LAYOUT_VERSION = '2'
19+
const OG_SKILL_IMAGE_LAYOUT_VERSION = '3'
2020

2121
export function getSiteUrl() {
2222
return import.meta.env.VITE_SITE_URL ?? DEFAULT_SITE

0 commit comments

Comments
 (0)