One stylesheet, many card types: a themeable render pipeline with CSS variables and Playwright
Shipped
v0.4.0 pulled Ghostwriter’s existing image card types, a date card, a flow-diagram card, a ramp/analytics card, and a STEM card, onto one shared visual base instead of each carrying its own colors and spacing. Two new card types landed on that same base: a code card that renders a snippet as a terminal window with line numbers and syntax colors, and a Claude Code card that shows a session transcript in the same terminal shell. The carousel got rebuilt too, from a square single-image deck into a portrait 4:5 multi-slide PDF with a progress bar and slide counter on every page.
The mechanism underneath all of it is worth its own walkthrough: a stylesheet built from CSS custom properties, paired with a headless browser that screenshots HTML elements to PNG and stitches multiple pages into a PDF. That combination is not specific to LinkedIn cards. It is a general way to turn styled HTML into shareable images or documents from code, and you can have a working version of it running in about twenty minutes.
Set up: what the renderer needs
The renderer is Python plus Playwright driving a headless Chromium. Nothing else.
python3 -m venv .venv
.venv/bin/pip install playwright
.venv/bin/playwright install chromium
The project layout is three kinds of file: one shared stylesheet, one HTML file per card (just the content, no styling), and a render script that loads the HTML, inlines the CSS, and screenshots the result.
Build: a shared base, tokens instead of hardcoded values
The stylesheet starts with a :root block of CSS custom properties for every value the card family shares: background, text color, accent, fonts. A .card base rule then reads those properties instead of hardcoding them. MDN’s guide to custom properties frames the payoff directly: a value declared once and referenced with var() everywhere else gives “one canonical declaration of the desired property value, which is very useful if you want to change the value across the entire project later” (MDN: Using CSS custom properties).
/* cards.css */
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #9da7b3;
--accent: #58a6ff;
--font: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
--mono: "SF Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); }
/* The base every card type inherits from: canvas, color, type, claimed once. */
.card {
width: 1080px;
height: 1350px;
padding: 96px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 28px;
font-family: var(--font);
color: var(--text);
background: var(--bg);
}
.card .eyebrow {
font: 600 26px/1 var(--mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.card h1 { font: 800 72px/1.05 var(--font); margin: 0; }
Retheming the family later, or switching every card from dark to light, means changing the :root block once. Nothing downstream needs to change because nothing downstream owns a raw color value.
Build: a card type is just its own deltas
A card type is a second class chained onto .card, and it only defines what’s unique about that type. It never restates the background, the canvas size, or the font, because the base rule already set those.
/* a hero-number card type: nothing here restates the base */
.card.stat .value { font: 800 160px/1 var(--font); color: var(--accent); }
.card.stat .label { font: 500 36px/1.3 var(--font); color: var(--muted); }
<!-- stat-card.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="cards.css" />
</head>
<body>
<div class="card stat">
<div class="eyebrow">DEVLOG</div>
<div class="value">01</div>
<div class="label">one shared stylesheet, every card type reads from it</div>
</div>
</body>
</html>
That is the cost of a new type: a handful of lines describing one shape, authored as plain content-only HTML with no inline styling.
Use it: render to PNG, then verify
Playwright can screenshot a single element instead of the entire page, which is what turns arbitrary HTML into an image sized exactly to your card (Playwright: Screenshots). The one thing to get right first is that page.set_content() has no base URL, so a relative <link href="cards.css"> never resolves; the script has to inline the stylesheet into a <style> tag before handing the HTML to the page.
# render.py
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
HERE = Path(__file__).parent
CSS = HERE / "cards.css"
def inline_css(html: str) -> str:
css = CSS.read_text(encoding="utf-8")
return re.sub(
r'<link[^>]*href="[^"]*cards\.css"[^>]*>',
lambda _m: f"<style>\n{css}\n</style>",
html,
)
def render_card(html_path: Path, out_path: Path) -> None:
html = inline_css(html_path.read_text(encoding="utf-8"))
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1080, "height": 1350}, device_scale_factor=2)
page.set_content(html, wait_until="load")
page.locator(".card").first.screenshot(path=str(out_path))
browser.close()
if __name__ == "__main__":
render_card(Path(sys.argv[1]), Path(sys.argv[2]))
Run it against the stat card above and check what came out:
$ python render.py stat-card.html stat-card.png
$ file stat-card.png
stat-card.png: PNG image data, 2160 x 2700, 8-bit/color RGB, non-interlaced
2160x2700 is the 1080x1350 canvas doubled, because device_scale_factor=2 renders at retina density before Playwright writes the file. That’s a real render running end to end: one stylesheet, one content file, one PNG sized exactly to the card.
Scale it: a progress-bar deck stitched to one PDF
A carousel is the same pattern run in a loop: several .card.slide elements in one HTML file, each screenshotted individually, then composed into a single PDF page-by-page. The slide type adds a progress bar driven by two custom properties set inline per slide, --i for this slide’s number and --n for the total, so the fill width is a calc() off two numbers instead of a hardcoded percentage per slide.
/* slide type: a carousel page with a progress bar driven by --i/--n */
.card.slide {
--i: 1; --n: 3;
position: relative;
padding: 96px 96px 160px;
}
.card.slide .rail {
position: absolute; left: 96px; right: 96px; bottom: 96px;
height: 6px; border-radius: 3px; background: var(--surface);
}
.card.slide .rail::after {
content: ""; position: absolute; left: 0; top: 0; height: 100%;
border-radius: 3px; background: var(--accent);
width: calc(var(--i) / var(--n) * 100%);
}
Each slide sets style="--i:2;--n:3" inline; the CSS never hardcodes a slide count. Rendering loops over every .slide locator, screenshots each one, then hands the resulting PNGs to a second Playwright page whose @page CSS forces one image per PDF page, using page.pdf() to write the file (Playwright: Page.pdf()):
# render_carousel.py (add `import base64`; inline_css and the rest of the
# imports are the same as render.py)
def render_carousel(html_path, pdf_out):
html = inline_css(html_path.read_text(encoding="utf-8"))
stem = pdf_out.with_suffix("")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1080, "height": 1350})
page.set_content(html, wait_until="load")
slides = page.locator(".slide")
png_bytes = [
slides.nth(i).screenshot(path=str(f"{stem}-{i+1:02d}.png"))
for i in range(slides.count())
]
imgs = "".join(
f'<img src="data:image/png;base64,{base64.b64encode(b).decode()}" />'
for b in png_bytes
)
stitch = f"""<style>
@page {{ size: 1080px 1350px; margin: 0; }}
img {{ display: block; width: 1080px; height: 1350px; break-after: page; }}
img:last-child {{ break-after: auto; }}
</style>{imgs}"""
pdf_page = browser.new_page()
pdf_page.set_content(stitch, wait_until="load")
pdf_page.pdf(path=str(pdf_out), width="1080px", height="1350px",
print_background=True, margin={"top": "0", "bottom": "0", "left": "0", "right": "0"})
browser.close()
if __name__ == "__main__":
render_carousel(Path(sys.argv[1]), Path(sys.argv[2]))
print_background=True matters here: page.pdf() renders with print CSS media by default, which can drop background colors unless you tell it to keep them. Run it against a three-slide file and the output confirms it:
$ python render_carousel.py carousel.html carousel.pdf
slide 1/3 -> carousel-01.png
slide 2/3 -> carousel-02.png
slide 3/3 -> carousel-03.png
Rendered 3-slide carousel -> carousel.pdf
$ file carousel.pdf
carousel.pdf: PDF document, version 1.4, 3 pages
Three slides in, three PDF pages out, each with its progress bar filled to the right fraction.
Gotchas
A relative stylesheet link silently fails. Reach for <link rel="stylesheet" href="cards.css"> in the card HTML the way you would in a normal page, and the render comes back unstyled with no error. page.set_content() has no base URL to resolve a relative path against, so the link just never loads. The fix is inlining the CSS into a <style> block before the HTML reaches the page, which is why both render scripts do that inline step first.
A hand-typed counter can drift from the progress bar. Ghostwriter’s real carousel pairs the --i/--n-driven progress bar with a literal “3 / 8” text counter on each slide, and a quality-gate pass on this release caught that the two are not linked. The custom properties only drive the bar’s fill width; the counter text is typed by hand and can say something different from what the bar shows if you update one and forget the other.
A fixed-size canvas clips overflow with no ellipsis. The code and Claude session cards render source lines onto a canvas of a fixed pixel width. A line longer than that width does not wrap or truncate with an ellipsis, it just gets cut off at the edge with the rest of the text gone and no visual indication anything is missing. The same review pass that caught the counter drift flagged this too: keep lines short enough to fit, because there’s no other safety net.
Sources
- MDN: Using CSS custom properties — a custom property declared once and read with
var()gives one canonical value to change later instead of many. - Playwright: Screenshots —
locator.screenshot()captures a single element instead of the full page. - Playwright: Page.pdf() — generates a PDF from page content using print CSS media by default, with a
print_backgroundoption to preserve background colors.
Changelog
- ghostwriter 0.4.0: card-family redesign, code/claude cards, portrait carousel (#12) (206e3f8)