2
0

refactor(socialcard): redesign card layouts and add portrait format
All checks were successful
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m46s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Successful in 5m22s
Build and Release / Unit Tests (push) Successful in 5m35s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m7s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m40s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 8m6s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m50s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m27s

Redesign preset card layout with left accent stripe, centered avatar, and vertically centered text blocks. Add portrait format (1080x1350) for solid and image styles with split layout. Improve text wrapping and truncation logic. Update solid card to support background image in bottom half with solid color top section.
This commit is contained in:
2026-01-30 23:23:38 -05:00
parent 0d4e6aecdb
commit 7c1595859e
3 changed files with 191 additions and 142 deletions

View File

@@ -37,6 +37,10 @@ var logoData []byte
const (
CardWidth = 1200
CardHeight = 630
// Portrait dimensions for solid and image styles.
PortraitWidth = 1080
PortraitHeight = 1350
)
// Theme defines the color scheme for a social card.
@@ -108,10 +112,15 @@ func IsValidStyle(name string) bool {
}
// GetTheme returns a theme by name, falling back to dark.
// For "solid" and "image" styles, returns a placeholder theme with the correct Name
// so the RenderCard dispatcher routes to the right renderer.
func GetTheme(name string) Theme {
if t, ok := ThemesByName[name]; ok {
return t
}
if name == "solid" || name == "image" {
return Theme{Name: name}
}
return ThemeDark
}
@@ -171,8 +180,8 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error)
// Fill background
fillRect(img, img.Bounds(), theme.Background)
// Top accent bar (4px)
fillRect(img, image.Rect(0, 0, CardWidth, 4), theme.CardBorderColor)
// Left accent stripe (6px wide)
fillRect(img, image.Rect(0, 0, 6, CardHeight), theme.CardBorderColor)
faces, err := r.createFaces()
if err != nil {
@@ -180,47 +189,110 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error)
}
defer faces.close()
padding := 60
avatarSize := 96
avatarGap := 24
leftPad := 60
rightPad := 60
avatarSize := 140
avatarGap := 40
// Repo avatar (top-right, rounded)
// Repo avatar (right side, vertically centered, rounded)
hasAvatar := false
if data.RepoAvatarURL != "" {
avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize)
if avatarImg != nil {
avatarX := CardWidth - padding - avatarSize
avatarY := padding + 20
drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg)
avatarX := CardWidth - rightPad - avatarSize
avatarY := (CardHeight - avatarSize) / 2
drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 16, avatarImg)
hasAvatar = true
}
}
// Text area width: shrink if avatar is present to avoid overlap
contentWidth := CardWidth - 2*padding
// Text area width: shrink if avatar is present
contentWidth := CardWidth - leftPad - rightPad
if hasAvatar {
contentWidth = CardWidth - 2*padding - avatarSize - avatarGap
contentWidth = CardWidth - leftPad - rightPad - avatarSize - avatarGap
}
r.drawCardText(img, faces, data, theme, padding, contentWidth)
// Calculate total text block height for vertical centering
titleLines := wrapText(data.Title, faces.title, contentWidth)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth)
}
titleBlockH := len(titleLines) * 60
var descLines []string
descBlockH := 0
if data.Description != "" {
descLines = wrapText(data.Description, faces.desc, contentWidth)
if len(descLines) > 3 {
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth)
}
descBlockH = len(descLines)*34 + 16 // 16 = gap between title and desc
}
metaH := 28 // height of bottom metadata line
gapBeforeMeta := 24
totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH
// Vertically center the text block
startY := (CardHeight-totalH)/2 + 48 // baseline of first title line
// Draw title
for i, line := range titleLines {
drawText(img, leftPad, startY+i*60, line, faces.title, theme.TitleColor)
}
// Draw description
descY := startY + titleBlockH + 16
for i, line := range descLines {
drawText(img, leftPad, descY+i*34, line, faces.desc, theme.DescColor)
}
// Bottom metadata: language + repo name
metaY := descY + descBlockH + gapBeforeMeta
xCursor := leftPad
if data.LanguageName != "" {
langColor := parseHexColor(data.LanguageColor)
drawCircle(img, xCursor+8, metaY-6, 8, langColor)
xCursor += 24
drawText(img, xCursor, metaY, data.LanguageName, faces.meta, theme.SubtextColor)
xCursor += measureText(data.LanguageName, faces.meta) + 30
}
drawText(img, xCursor, metaY, data.RepoFullName, faces.meta, theme.SubtextColor)
// GitCaddy logo (bottom right)
logoBounds := r.logo.Bounds()
logoX := CardWidth - rightPad - logoBounds.Dx()
logoY := CardHeight - 40 - logoBounds.Dy()
draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()),
r.logo, logoBounds.Min, draw.Over)
// Encode to PNG
return encodePNG(img)
}
// renderSolidCard renders a card with a user-chosen solid background color
// and auto-contrast text colors.
// renderSolidCard renders a portrait card with a solid-color top half
// containing center-aligned text, and the background image in the bottom half.
// If no background image is provided, the solid color fills the entire card.
func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
w, h := PortraitWidth, PortraitHeight
img := image.NewRGBA(image.Rect(0, 0, w, h))
bgColor := parseHexColor(data.SolidColor)
theme := contrastTheme(bgColor)
// Fill entire card with solid color first
fillRect(img, img.Bounds(), bgColor)
// Top accent bar — slightly lighter/darker than background
accentColor := shiftColor(bgColor, 40)
fillRect(img, image.Rect(0, 0, CardWidth, 4), accentColor)
// If there's a background image, draw it in the bottom half
splitY := h / 2
if data.BgImage != nil {
bottomH := h - splitY
cropped := coverCrop(data.BgImage, w, bottomH)
draw.Draw(img,
image.Rect(0, splitY, w, h),
cropped, cropped.Bounds().Min, draw.Src)
}
faces, err := r.createFaces()
if err != nil {
@@ -228,56 +300,89 @@ func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) {
}
defer faces.close()
padding := 60
avatarSize := 96
avatarGap := 24
pad := 80
contentWidth := w - 2*pad
hasAvatar := false
if data.RepoAvatarURL != "" {
avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize)
if avatarImg != nil {
avatarX := CardWidth - padding - avatarSize
avatarY := padding + 20
drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg)
hasAvatar = true
// Build text block for the top half, center-aligned
titleLines := wrapText(data.Title, faces.title, contentWidth)
if len(titleLines) > 3 {
titleLines = titleLines[:3]
titleLines[2] = truncateWithEllipsis(titleLines[2], faces.title, contentWidth)
}
titleBlockH := len(titleLines) * 60
var descLines []string
descBlockH := 0
if data.Description != "" {
descLines = wrapText(data.Description, faces.desc, contentWidth)
if len(descLines) > 3 {
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth)
}
descBlockH = len(descLines)*34 + 20
}
contentWidth := CardWidth - 2*padding
if hasAvatar {
contentWidth = CardWidth - 2*padding - avatarSize - avatarGap
metaH := 28
gapBeforeMeta := 20
totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH
// Vertically center in the top half
topCenter := splitY / 2
startY := topCenter - totalH/2 + 48
// Draw title (centered)
for i, line := range titleLines {
lineW := measureText(line, faces.title)
x := (w - lineW) / 2
drawText(img, x, startY+i*60, line, faces.title, theme.TitleColor)
}
r.drawCardText(img, faces, data, theme, padding, contentWidth)
// Draw description (centered)
descY := startY + titleBlockH + 20
for i, line := range descLines {
lineW := measureText(line, faces.desc)
x := (w - lineW) / 2
drawText(img, x, descY+i*34, line, faces.desc, theme.DescColor)
}
// Repo full name (centered, smaller)
metaY := descY + descBlockH + gapBeforeMeta
repoW := measureText(data.RepoFullName, faces.meta)
drawText(img, (w-repoW)/2, metaY, data.RepoFullName, faces.meta, theme.SubtextColor)
// GitCaddy logo (bottom right)
logoBounds := r.logo.Bounds()
logoX := w - pad - logoBounds.Dx()
logoY := h - 40 - logoBounds.Dy()
draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()),
r.logo, logoBounds.Min, draw.Over)
return encodePNG(img)
}
// renderImageCard renders a card with a full-bleed background image and
// a dark gradient scrim at the bottom for text readability.
// renderImageCard renders a portrait card with a full-bleed background image,
// a strong dark gradient scrim over the bottom ~45%, and white text at the bottom-left.
func (r *Renderer) renderImageCard(data CardData) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
w, h := PortraitWidth, PortraitHeight
img := image.NewRGBA(image.Rect(0, 0, w, h))
// Draw background image (cover-crop to fill)
if data.BgImage != nil {
cropped := coverCrop(data.BgImage, CardWidth, CardHeight)
cropped := coverCrop(data.BgImage, w, h)
draw.Draw(img, img.Bounds(), cropped, cropped.Bounds().Min, draw.Src)
} else {
// Fallback to dark if no image
fillRect(img, img.Bounds(), ThemeDark.Background)
}
// Draw gradient scrim over bottom 50%
scrimStartY := CardHeight * 50 / 100
drawGradientScrim(img, scrimStartY, CardHeight, 200)
// Strong gradient scrim over bottom 45%
scrimStartY := h * 55 / 100
drawGradientScrim(img, scrimStartY, h, 220)
// White text on scrim
theme := Theme{
TitleColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
DescColor: color.RGBA{R: 220, G: 220, B: 220, A: 255},
SubtextColor: color.RGBA{R: 180, G: 180, B: 180, A: 255},
AccentColor: color.RGBA{R: 100, G: 160, B: 255, A: 255},
}
titleColor := color.RGBA{R: 255, G: 255, B: 255, A: 255}
descColor := color.RGBA{R: 230, G: 230, B: 230, A: 255}
subtextColor := color.RGBA{R: 170, G: 170, B: 170, A: 255}
faces, err := r.createFaces()
if err != nil {
@@ -285,76 +390,63 @@ func (r *Renderer) renderImageCard(data CardData) ([]byte, error) {
}
defer faces.close()
padding := 60
// Repo avatar top-right (with slight shadow effect for visibility)
avatarSize := 80
if data.RepoAvatarURL != "" {
avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize)
if avatarImg != nil {
avatarX := CardWidth - padding - avatarSize
avatarY := padding
drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg)
}
}
// Text at bottom on scrim — use larger title for impact
contentWidth := CardWidth - 2*padding
bottomPadding := 60
pad := 80
contentWidth := w - 2*pad
bottomPad := 80
// Build text from bottom up
yBottom := CardHeight - bottomPadding
yBottom := h - bottomPad
// Repo full name (bottom-most)
drawText(img, padding, yBottom, data.RepoFullName, faces.meta, theme.SubtextColor)
yBottom -= 30
// Repo full name (bottom-most, small caps style)
drawText(img, pad, yBottom, data.RepoFullName, faces.meta, subtextColor)
yBottom -= 36
// Unsplash attribution
// Unsplash attribution (right-aligned on same line as repo name)
if data.UnsplashAuthor != "" {
attrText := "Photo by " + data.UnsplashAuthor
attrX := CardWidth - padding - measureText(attrText, faces.meta)
drawText(img, attrX, CardHeight-bottomPadding, attrText, faces.meta, theme.SubtextColor)
attrX := w - pad - measureText(attrText, faces.meta)
drawText(img, attrX, yBottom+36, attrText, faces.meta, subtextColor)
}
// Language indicator
if data.LanguageName != "" {
langColor := parseHexColor(data.LanguageColor)
drawCircle(img, padding+8, yBottom-6, 8, langColor)
drawText(img, padding+24, yBottom, data.LanguageName, faces.meta, theme.SubtextColor)
yBottom -= 30
drawCircle(img, pad+8, yBottom-6, 8, langColor)
drawText(img, pad+24, yBottom, data.LanguageName, faces.meta, subtextColor)
yBottom -= 36
}
yBottom -= 10
yBottom -= 16
// Description (max 2 lines for image style)
// Description (max 3 lines)
if data.Description != "" {
descLines := wrapText(data.Description, faces.desc, contentWidth)
if len(descLines) > 2 {
descLines = descLines[:2]
descLines[1] = truncateWithEllipsis(descLines[1], faces.desc, contentWidth)
if len(descLines) > 3 {
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth)
}
for i := len(descLines) - 1; i >= 0; i-- {
drawText(img, padding, yBottom, descLines[i], faces.desc, theme.DescColor)
drawText(img, pad, yBottom, descLines[i], faces.desc, descColor)
yBottom -= 34
}
yBottom -= 6
yBottom -= 10
}
// Title (max 2 lines)
// Title (max 3 lines, large)
titleLines := wrapText(data.Title, faces.title, contentWidth)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth)
if len(titleLines) > 3 {
titleLines = titleLines[:3]
titleLines[2] = truncateWithEllipsis(titleLines[2], faces.title, contentWidth)
}
for i := len(titleLines) - 1; i >= 0; i-- {
drawText(img, padding, yBottom, titleLines[i], faces.title, theme.TitleColor)
drawText(img, pad, yBottom, titleLines[i], faces.title, titleColor)
yBottom -= 60
}
// GitCaddy logo (bottom right)
logoBounds := r.logo.Bounds()
logoX := CardWidth - padding - logoBounds.Dx()
logoY := CardHeight - padding - logoBounds.Dy()
logoX := w - pad - logoBounds.Dx()
logoY := h - pad + 10 - logoBounds.Dy()
draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()),
r.logo, logoBounds.Min, draw.Over)
@@ -399,55 +491,6 @@ func (r *Renderer) createFaces() (*fontFaces, error) {
return &fontFaces{title: titleFace, desc: descFace, meta: metaFace}, nil
}
// drawCardText draws title, description, language, repo name, and logo
// using the standard layout (used by preset and solid styles).
func (r *Renderer) drawCardText(img *image.RGBA, faces *fontFaces, data CardData, theme Theme, padding, contentWidth int) {
// Draw title (max 2 lines)
titleY := 80 + 48
titleLines := wrapText(data.Title, faces.title, contentWidth)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth)
}
for i, line := range titleLines {
drawText(img, padding, titleY+i*60, line, faces.title, theme.TitleColor)
}
// Draw description (max 3 lines)
descY := titleY + len(titleLines)*60 + 20
if data.Description != "" {
descLines := wrapText(data.Description, faces.desc, contentWidth)
if len(descLines) > 3 {
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth)
}
for i, line := range descLines {
drawText(img, padding, descY+i*34, line, faces.desc, theme.DescColor)
}
}
// Bottom section: language + repo full name
bottomY := CardHeight - 80
xCursor := padding
if data.LanguageName != "" {
langColor := parseHexColor(data.LanguageColor)
drawCircle(img, xCursor+8, bottomY-6, 8, langColor)
xCursor += 24
drawText(img, xCursor, bottomY, data.LanguageName, faces.meta, theme.SubtextColor)
xCursor += measureText(data.LanguageName, faces.meta) + 30
}
drawText(img, xCursor, bottomY, data.RepoFullName, faces.meta, theme.SubtextColor)
// GitCaddy logo (bottom right)
logoBounds := r.logo.Bounds()
logoX := CardWidth - padding - logoBounds.Dx()
logoY := CardHeight - padding - logoBounds.Dy()
draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()),
r.logo, logoBounds.Min, draw.Over)
}
// fillRect fills a rectangle with a solid color.
func fillRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) {
for y := rect.Min.Y; y < rect.Max.Y; y++ {
@@ -662,10 +705,11 @@ func drawGradientScrim(img *image.RGBA, startY, endY int, maxAlpha uint8) {
if height <= 0 {
return
}
imgW := img.Bounds().Dx()
for y := startY; y < endY; y++ {
progress := float64(y-startY) / float64(height)
alpha := uint8(float64(maxAlpha) * progress)
for x := range CardWidth {
for x := range imgW {
r, g, b, a := img.At(x, y).RGBA()
// Blend with black at the computed alpha
blendR := uint8((r>>8)*(255-uint32(alpha))/255 + 0)