Ghostwriter

One class, every token: theming a visual system with CSS custom properties

Shipped

Ghostwriter generates the images that ride along with a LinkedIn post: explainer cards, comparison scorecards, code snippets, carousels. Version 0.8.0 rebuilt all ten card templates plus the Mermaid diagram theme, moving them from a flat dark look to a lighter, more restrained one with layered panels and a dark callout band for emphasis. The palette is the least interesting part. What kept a ten-template migration manageable is that every one of them re-themes by adding a single CSS class, because the styling runs entirely on custom properties and the final PNGs come from a headless browser screenshot. That combination, tokenized CSS plus a deterministic render step, is worth building yourself, so here’s the whole thing end to end.

Set up the token layer

A design token is a named variable that holds one design decision, like a background color or a border width, so every component references the name instead of the raw value. The W3C Design Tokens Community Group describes design tokens as the single source of truth for colors, typography, and spacing, with theming (light/dark, brand variants) as one of the payoffs of keeping that source in one place.

In plain CSS you get tokens for free with custom properties. Define the semantic set once at the root and treat it as the default theme:

/* tokens.css: the single source of truth. Components never hardcode a color. */
:root {
  --bg:      #0d1117;  /* the canvas */
  --surface: #161b22;  /* a panel fill */
  --border:  #30363d;
  --text:    #e6edf3;  /* primary text */
  --muted:   #9da7b3;  /* secondary text */
  --accent:  #2d7df6;
}

The rule that makes everything downstream work: a component may read var(--text), but it may never write #e6edf3 directly. The moment a raw hex value shows up in a component rule, that piece of it stops re-theming.

Override the whole set on one class

Custom properties don’t behave like variables in most languages. MDN is specific about this: the value is computed where it’s needed, not stored and reused elsewhere in the stylesheet, and a property “is only set for the matching selector and its descendants.” So redefining the token set on one ancestor class re-themes every descendant that reads those tokens, without touching a single component rule:

/* light-fixed.css: one class redefines every token the components read. */
.light {
  --bg:      #f7f8fa;
  --surface: #ffffff;
  --border:  #e6e9ee;
  --text:    #0c111b;
  --muted:   #727b8a;
  --accent:  #2d7df6;
}

/* A component reads tokens only. It has no idea which theme is active. */
.panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 22px;
  padding: 34px 38px;
}
.panel .title { color: var(--text); font: 800 40px/1.1 sans-serif; margin: 0 0 10px; }
.panel .sub   { color: var(--muted); font: 400 24px/1.4 sans-serif; margin: 0; }

<div class="card"> renders dark, <div class="card light"> renders light, and .panel never changed. The component describes structure in terms of tokens; the theme is just which values are in scope for that subtree. A third theme is one more class with one more value set, not a rewrite of every rule that touches color.

Render the HTML to a PNG

A stylesheet isn’t an image. The render step loads a fixed-size HTML card in headless Chromium and screenshots one element. Playwright makes both parts short: you can screenshot a single element clipped to its own box with locator.screenshot(), and you can emulate a high-DPI display by setting device_scale_factor to 2 on the browser context so every CSS pixel becomes two image pixels.

# render.py — turn a themed #canvas into a high-DPI PNG, deterministically.
from pathlib import Path
from playwright.sync_api import sync_playwright

def build_html(theme_file: str) -> str:
    tokens = Path("tokens.css").read_text()
    theme = Path(theme_file).read_text()
    html = Path("card.html").read_text()
    return html.replace("%%TOKENS%%", tokens).replace("%%THEME%%", theme)

def render(theme_file: str, out: Path, width: int = 900, height: int = 500) -> None:
    html = build_html(theme_file)
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(
            viewport={"width": width, "height": height},
            device_scale_factor=2,           # 2 image pixels per CSS pixel
        )
        page.set_content(html, wait_until="load")
        page.wait_for_selector("#canvas")
        page.locator("#canvas").screenshot(path=str(out))   # clipped to the element
        browser.close()

card.html is the fixed-size canvas with the theme class on it. build_html swaps its two placeholders for the token file and whichever theme file you pass in, so the page has no external <link> to resolve:

<html>
<head>
<style>%%TOKENS%%</style>
<style>%%THEME%%</style>
</head>
<body>
  <div id="canvas" class="card light" style="width:900px;height:500px">
    <div class="panel">
      <p class="title">Ship the light theme</p>
      <p class="sub">One class flips every token at once.</p>
    </div>
  </div>
</body>
</html>

Use it, then verify it

To see why a complete override matters, save a second theme file next to light-fixed.css, one that forgets a single token:

/* light-broken.css — flips the obvious tokens, forgets --text. */
.light {
  --bg:      #f7f8fa;
  --surface: #ffffff;
  --border:  #e6e9ee;
  --muted:   #727b8a;
  --accent:  #2d7df6;
  /* --text is NOT redefined here, so it falls through to #e6edf3 from :root */
}

Render the same markup through both theme files and you get two PNGs from identical HTML:

$ python render.py light-fixed.css fixed.png
rendered light-fixed.css -> fixed.png
$ python render.py light-broken.css broken.png
rendered light-broken.css -> broken.png

The render is deterministic: same HTML, same tokens, same pixels every time, which is what makes a generated-image pipeline safe to automate. But “it rendered” isn’t the same as “it’s correct,” so don’t stop at the file existing. A cheap CI check reads the computed color of a themed element and fails if it still resolves to the dark-theme value:

# verify.py — fail the build if a themed element leaks the dark-mode color.
from playwright.sync_api import sync_playwright
from render import build_html

DARK_TEXT = "rgb(230, 237, 243)"  # --text from :root, i.e. #e6edf3

def computed_title_color(theme_file: str) -> str:
    html = build_html(theme_file)
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html, wait_until="load")
        color = page.locator(".title").first.evaluate(
            "el => getComputedStyle(el).color"
        )
        browser.close()
        return color

for theme_file in ("light-fixed.css", "light-broken.css"):
    color = computed_title_color(theme_file)
    print(f"{theme_file}: .title color = {color}  "
          f"[{'PASS' if color != DARK_TEXT else 'FAIL'}]")

Running it against a correct theme override and a deliberately incomplete one:

$ python verify.py
light-fixed.css: .title color = rgb(12, 17, 27)  [PASS]
light-broken.css: .title color = rgb(230, 237, 243)  [FAIL]

That FAIL line is the whole gotcha below, caught before anyone has to squint at a PNG.

Gotchas

Redefine the obvious tokens and one component goes invisible. light-broken.css above isn’t a hypothetical; it’s the same shape of bug that hit the flow card during the actual migration. The first pass at its .light override redefined the background, the surface, the border, everything that read as “clearly a light-mode color.” --text didn’t look like it needed touching, so it stayed at its :root value, a near-white meant for the dark canvas. On a white panel that’s near-white text: readable in the DOM inspector, invisible in the screenshot. The fix wasn’t in the component; color: var(--text) was doing exactly what it should. The fix was adding the missing token, --text and (in the real migration) --surface-2 alongside it, to the override, so the modifier class redefines the complete set a component might read, not just the ones that looked obviously theme-specific.

The failure mode is specific to tokens: a missed one doesn’t error, it just quietly resolves to whatever value was last set in the cascade. Rendering the PNG and looking at it catches this, eventually. A computed-style assertion like verify.py above catches it before anyone has to look, which matters once there’s more than one or two card types to keep in sync.

Sources

Changelog

  • feat(ghostwriter): modern light card design system (0.8.0) (adb2f5d)