HTML/SVG image cards for LinkedIn, X, Substack, and WhatsApp Status. Two-format workflow: landscape for OG images, square for social posts.
Create HTML/SVG image cards for LinkedIn, Twitter/X, Substack, and WhatsApp Status. Optimized for mobile readability.
Reference files:references/platform-dimensions.md (detailed platform dimensions and research), references/mobile-preview-template.md (phone-frame HTML for mobile testing).
Every card needs two versions. They share the same content, just different canvas shapes.
| Format | Dimensions | Aspect Ratio | Use For |
|---|---|---|---|
| Landscape | 1200×628 | 1.91:1 | Website featured image, OG image, link previews (Substack, blog, X link cards) |
| Square | 1080×1080 | 1:1 | Social media feed posts (LinkedIn, X/Twitter, WhatsApp Status) |
card-landscape.html + card-square.html, render both PNGs. Use landscape for the blog post featured image, square when posting on social.
| Platform | Feed Post | Link Preview | Notes |
|---|---|---|---|
| 1080×1080 (1:1) or 1080×1350 (4:5) | 1200×627 (1.91:1) | Square gets more mobile feed space than landscape | |
| X/Twitter | 1200×675 (16:9) or 1080×1080 (1:1) | 1200×628 (1.91:1) | Square works well, takes more vertical feed space |
| Substack | 1456×1048 (14:10) social preview | Same | Articles are 730px wide; 1080×1080 works as article header |
| WhatsApp Status | 1080×1920 (9:16) | N/A | Full-screen vertical; square image gets centered with colored bars |
Social platforms scale images to fit mobile viewports (~375px wide). A 1200px card displays at ~31% scale on mobile. What feels "too big" on desktop is just right on mobile.
| Element | Minimum | Recommended |
|---|---|---|
| Main text/labels | 22px | 26-32px |
| Sublabels | 14px | 16-18px |
| Annotations | 12px | 14-16px |
| Fine print | — | Remove entirely |
| Element | Minimum | Recommended |
|---|---|---|
| Headline | 42px | 48-52px |
| Body/excerpt | 22px | 26-28px |
| Brand/footer | 16px | 18px |
| URL | 13px | 15px |
Before shipping a card, always preview at mobile scale:
1. Create a phone-frame mockup HTML with realistic social platform UI
2. Render the card as PNG (via browser screenshot at 1:1)
3. Embed the PNG in the mockup at 375px width
4. Take a screenshot and verify readability
See references/mobile-preview-template.md for the phone-frame HTML template.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,600;1,400&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">
<style>
html, body { margin: 0; padding: 0; width: 1200px; height: 628px; overflow: hidden; }
* { margin: 0; padding: 0; box-sizing: border-box; }
.card { width: 1200px; height: 628px; background: #fff; display: flex; overflow: hidden; }
.visual { flex: 0 0 580px; background: #f8f8fa; border-right: 1px solid rgba(54,55,55,0.10); display: flex; align-items: center; justify-content: center; }
.visual svg { width: 100%; height: 100%; }
.text { flex: 1; padding: 48px; display: flex; flex-direction: column; justify-content: center; }
.text h2 { font-family: 'Spectral', serif; font-size: 48px; font-weight: 600; line-height: 1.15; color: #363737; }
.accent-bar { width: 56px; height: 4px; background: #4f46e5; border-radius: 2px; margin: 16px 0 24px; }
.text .excerpt { font-family: 'Spectral', serif; font-size: 26px; line-height: 1.5; color: #757575; font-style: italic; }
.footer { position: absolute; bottom: 0; left: 580px; right: 0; padding: 16px 48px; border-top: 1px solid rgba(54,55,55,0.10); display: flex; justify-content: flex-end; }
.footer .url { font-family: 'Source Code Pro', monospace; font-size: 15px; color: #757575; }
</style>
</head>
<body>
<div class="card">
<div class="visual"><!-- SVG diagram here --></div>
<div class="text">
<h2>Title</h2>
<div class="accent-bar"></div>
<p class="excerpt">Subtitle text</p>
</div>
<div class="footer">
<span class="url">jjude.com</span>
</div>
</div>
</body>
</html>
Footer rule: Only the site URL on the right (justify-content: flex-end). No brand name on the left — it's redundant for personal blogs.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,600;1,400&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">
<style>
html, body { margin: 0; padding: 0; width: 1080px; height: 1080px; overflow: hidden; }
* { margin: 0; padding: 0; box-sizing: border-box; }
.card { width: 1080px; height: 1080px; background: #fff; display: flex; flex-direction: column; overflow: hidden; }
.text-block { padding: 52px 60px 32px 60px; text-align: center; }
.text-block h2 { font-family: 'Spectral', serif; font-size: 52px; font-weight: 600; line-height: 1.15; color: #363737; }
.accent-bar { width: 56px; height: 4px; background: #4f46e5; border-radius: 2px; margin: 16px auto 20px; }
.text-block .excerpt { font-family: 'Spectral', serif; font-size: 28px; line-height: 1.5; color: #757575; font-style: italic; }
.visual { flex: 1; background: #f8f8fa; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.visual svg { width: 100%; height: 100%; display: block; }
.footer { padding: 20px 60px 28px; border-top: 1px solid rgba(54,55,55,0.10); display: flex; justify-content: flex-end; }
.footer .url { font-family: 'Source Code Pro', monospace; font-size: 15px; color: #757575; }
</style>
</head>
<body>
<div class="card">
<div class="text-block">
<h2>Title</h2>
<div class="accent-bar"></div>
<p class="excerpt">Subtitle text</p>
</div>
<div class="visual"><!-- SVG diagram here --></div>
<div class="footer">
<span class="url">jjude.com</span>
</div>
</div>
</body>
</html>
Before generating any image, show the user a text/ASCII diagram of the layout. This prevents wasted render cycles on wrong layouts.
Steps:1. Draw the diagram layout in text (use monospace art). Include: node positions, label placement, spacing.
2. For each format, specify where text and diagram go:
3. Propose 2-3 title/subtitle options. Let the user pick.
4. Only after user confirms the layout AND text, generate the HTML cards and render PNGs.
Confirmed layout conventions (Joseph):.visual div LEFT (580px), .text div RIGHT. Footer with jjude.com brand..text-block TOP, .visual flex-1 below. Footer with jjude.com brand.jjude.com in the footer.browser_vision for rendering. The browser tool captures the full viewport (1280×720), not the card element. A 1080×1080 square card extends below the viewport, and a 1200×628 landscape card has white space around it. The result always has a white "border" from the browser background.
Use Playwright with Chromium to take element-level screenshots:
# Setup (one-time)
uv venv /tmp/pw-env && source /tmp/pw-env/bin/activate
uv pip install playwright
PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers python3 -m playwright install chromium
# render_cards.py
import asyncio
from playwright.async_api import async_playwright
CARDS = [
{"html": "/path/to/card.html", "output": "/path/to/card.png", "selector": ".card"},
{"html": "/path/to/card-square.html", "output": "/path/to/card-square.png", "selector": ".card"},
]
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
for card in CARDS:
page = await browser.new_page()
await page.goto(f"file://{card['html']}")
await page.wait_for_load_state("networkidle")
element = await page.query_selector(card["selector"])
await element.screenshot(path=card["output"])
print(f"Saved: {card['output']}")
await page.close()
await browser.close()
asyncio.run(main())
Run with: source /tmp/pw-env/bin/activate && PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers python3 render_cards.py
Always add this to card HTML as a viewport-bleed safety net:
html, body { margin: 0; padding: 0; width: Npx; height: Npx; overflow: hidden; }
Where N matches the card dimensions (1200×628 for landscape, 1080×1080 for square).
When the card includes a diagram (hub-and-spoke, flow, architecture), follow these rules inside the SVG:
<marker> with orient="auto", never hand-drawn <polygon> triangles. Markers auto-rotate to match the line's angle. Hand-drawn triangles only look right on horizontal/vertical lines — they break on diagonals.
<defs>
<marker id="arrow-end" viewBox="0 0 10 10" refX="8" refY="5"
markerWidth="6.5" markerHeight="6.5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#4f46e5" fill-opacity="0.55"/>
</marker>
<marker id="arrow-start" viewBox="0 0 10 10" refX="2" refY="5"
markerWidth="6.5" markerHeight="6.5" orient="auto">
<path d="M10,0 L0,5 L10,10 z" fill="#4f46e5" fill-opacity="0.55"/>
</marker>
</defs>
Two separate markers (not auto-start-reverse) — better WebKit compatibility for headless rendering.
White fill, light gray stroke (#d1d5db), rx="6", drop shadow filter. Never flat gray fills.
Inside each node box (origin Y, height H):
box_x + width/2Arrows must start/end at element borders, not from arbitrary interior points. Calculate border intersection: e.g., for a box at (50, 46) width 130, the right border is x=180.
<filter id="ds" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#363737" flood-opacity="0.06"/>
</filter>
browser_vision for rendering — it captures the full viewport (1280×720), not the card element. A 1080×1080 card extends below the viewport; a 1200×628 card has white space around it. Always use Playwright element.screenshot() (see Rendering PNGs section)..visual div — if you want full-bleed color, set it on .card itself. Otherwise the text block and footer sit on white, creating a visible boundary. Alternatively, use preserveAspectRatio="xMidYMid slice" on the SVG to fill edge-to-edge.html, body { width: Npx; height: Npx; overflow: hidden; } to card HTML — prevents viewport bleed-through even if the rendering tool doesn't clip to the element<marker> with orient="auto" (see SVG Diagram Conventions).box_x + width/2).produce/hermes/social-media/images/generated/{slug}/ (NOT under blogs/jjude/ or blogs/ctofieldnotes/). Social media is cross-cutting for both blogs.