Ghostwriter

A new card type, and the case for growing a vocabulary over options

Shipped

Ghostwriter builds the visual cards that ship alongside a LinkedIn post, and v0.5.0 adds a matrix card: a comparison grid of rows and columns for posts whose point is a few options weighed against each other. It joins the existing family of ramp, flow, terminal, and carousel cards.

The card itself is small. The part worth writing about is the system it slots into and the rule that governs it. Each card type is a tiny, self-contained thing, and a new shape only becomes a type when it recurs, instead of every shape collapsing into one configurable mega-card. Here is the full path: how a type is declared and picked, how I added the matrix type, how it renders a real comparison, and the concrete bar a new type has to clear before it earns a place.

How a card type is declared and picked

A card type in Ghostwriter is not a class with a type flag. It is three small artifacts that share a name: an HTML template (assets/card-template-<type>.html), a block of scoped CSS (.card.<type> rules in assets/diagram.css), and a guidance entry in SKILL.md that tells the generator when to reach for it. The renderer, scripts/render_image.py, does not know any type names; it inlines the CSS into whatever HTML you hand it and screenshots the #canvas element. That is the whole registry, and it is deliberately file-based rather than coded.

This is the AHA instinct made structural, “Avoid Hasty Abstractions,” preferring a little duplication over a shared component that has to know about every shape at once (AHA Programming). Adding a type is adding a file and a CSS block, not editing a switch that every other type also runs through. The template is the declaration: it states the type’s shape and its contract in one place.

<!-- assets/card-template-matrix.html — the matrix type's declaration -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <link rel="stylesheet" href="diagram.css" />
</head>
<body>
  <div id="canvas" class="card matrix">
    <div class="toprow">
      <div class="eyebrow">EYEBROW LABEL</div>
      <div class="footer brand"></div> <!-- byline; a feed crop can't remove it -->
    </div>

    <h1>One sharp headline.</h1>
    <p class="sub">A single supporting line, if needed.</p>

    <!-- columns are set per-instance: 1.3fr label gutter + one 1fr per option -->
    <div class="grid" style="grid-template-columns: 1.3fr repeat(3, 1fr)">
      <div></div>                       <!-- empty corner above the row gutter -->
      <div class="col-h green">Option A</div>
      <div class="col-h grey">Option B</div>
      <div class="col-h pink">Option C</div>

      <div class="switch">a number group</div>  <!-- spans all columns as a divider -->
      <div class="dial">Row label</div>
      <div class="v">1</div><div class="v">5</div><div class="v">9</div>  <!-- .v = big mono number -->

      <div class="switch">a plain-english group</div>
      <div class="dial">Behaves</div>
      <div class="vt">never</div><div class="vt">on a miss</div><div class="vt">below goal</div>  <!-- .vt = short phrase -->
    </div>
  </div>
</body>
</html>

The contract is small and enforced by the class names: header cells (.col-h) carry a column accent, a value cell is either a number (.v) or a short phrase (.vt), and a .switch row groups the rows below it. The #canvas id and the diagram.css link are the only things the renderer requires, which is why every type can be a flat file.

Adding the matrix type

Most of the type lives in CSS, because the template only names structure and the look comes from the shared brand guide. The matrix rules turn that flat list of cells into an aligned grid: the first column is a label gutter, then one equal column per option, with a monospace number treatment for .v cells and a smaller sans treatment for .vt phrases. Keeping all of it under .card.matrix means it cannot leak into any other type.

/* assets/diagram.css — shared brand tokens, defined once and used by every card type.
   (Values shown are the project defaults; swap them for your own brand guide.) */
:root {
  --font: "Inter", system-ui, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, monospace;
  --accent:   #58A6FF;   /* group-divider (.switch) labels */
  --accent-2: #3FB950;   /* the "green" column accent */
  --accent-3: #F778BA;   /* the "pink" column accent */
  --muted:    #8B949E;   /* row labels */
}

/* the base card every type inherits: a fixed 1280x1280 dark canvas.
   This is what makes the light text colours below legible; render the same
   markup on a white, content-sized box and you get near-white text on white. */
.card {
  width: 1280px; height: 1280px;
  box-sizing: border-box;
  padding: 72px;
  background: #0D1117;
  color: #E6EDF3;
  font-family: var(--font);
}

/* the matrix type's rendering, scoped to .card.matrix */
.card.matrix { display: flex; flex-direction: column; }
.card.matrix h1 { font: 800 78px/1.04 var(--font); color: #E6EDF3; margin: 22px 0 8px; }
.card.matrix .grid {
  display: grid;
  /* no fixed track count here: the template sets grid-template-columns inline,
     one 1fr per option, so a 2-option card has no phantom empty column */
  align-items: center;
  row-gap: 24px;
  margin: auto 0;                           /* vertically center the table */
}
.card.matrix .col-h { font: 700 50px/1 var(--font); text-align: center; }
.card.matrix .green { color: var(--accent-2); }
.card.matrix .grey  { color: #C9D1D9; }
.card.matrix .pink  { color: var(--accent-3); }
.card.matrix .dial  { font: 500 40px/1.1 var(--font); color: var(--muted); }
.card.matrix .v  { font: 700 64px/1 var(--mono); color: #E6EDF3; text-align: center; } /* number */
.card.matrix .vt { font: 500 33px/1.18 var(--font); color: #C9D1D9; text-align: center; } /* phrase */
.card.matrix .switch {                       /* full-width group divider */
  grid-column: 1 / -1;
  font: 600 26px/1 var(--mono); color: var(--accent);
  letter-spacing: 0.07em; text-transform: uppercase;
}

The third artifact is the guidance entry, and it is the one that does the picking. The generator here is an LLM-driven skill that reads SKILL.md as part of its prompt, so it chooses a type from these descriptions rather than from any selection code; if you wire this registry into a generator that is not LLM-driven, you inherit the file-based registry and wiring discipline but supply your own selection logic. Because the model is doing the choosing, the entry has to say what the type is for and where its edges are. The edges matter here because not every “this versus that” deserves a grid. The Nielsen Norman Group’s guidance is specific: comparison tables earn their place when someone is deciding between a small number of items across multiple attributes (NN/g: Comparison Tables). One attribute is a sentence; a dozen options is a wall. The guidance encodes that narrow middle so the model reaches for the matrix only when the shape fits.

- `assets/card-template-matrix.html` — **matrix type** (a comparison): a labeled grid
  that compares a few options (columns) across the same attributes (rows). Header cells
  (`.col-h`) carry a column accent; value cells are `.v` for a big mono NUMBER or `.vt`
  for a short plain-English phrase; `.switch` rows group rows. Keep it to <=4 columns and
  <=7 rows so it stays scannable at phone size. Reach for it ONLY when a reader is choosing
  between a handful of options on several attributes that genuinely differ; if there's one
  attribute, write a sentence, and if there are a dozen options, a grid buries them.

Rendering a real comparison

To use the type, copy the template to images/<slug>.html, fill in the real options and attributes, and render. The renderer inlines diagram.css so the output is path-independent, then screenshots #canvas at 2x into a PNG. scripts/render_image.py is a thin Playwright wrapper; the part that matters is these few lines:

# scripts/render_image.py (essence): inline the CSS, crop to #canvas, save at 2x.
import re
from pathlib import Path
from playwright.sync_api import sync_playwright

def render(html_path: str, out_path: str) -> None:
    # inline the shared stylesheet so the page needs no web server or sibling files;
    # the template's relative <link href="diagram.css"> would otherwise dangle.
    css = Path("assets/diagram.css").read_text()
    html = Path(html_path).read_text()
    # match the link tag in any form (attribute order, quoting, self-closing slash)
    # rather than a byte-exact string that would silently no-op on the smallest drift.
    html = re.sub(r'<link[^>]+diagram\.css[^>]*>', f"<style>{css}</style>", html)
    assert "diagram.css" not in html, "stylesheet link was not inlined; card would be unstyled"
    with sync_playwright() as p:
        with p.chromium.launch() as browser:                  # closed on block exit
            page = browser.new_page(
                viewport={"width": 1280, "height": 1280},
                device_scale_factor=2,                        # 2x = crisp PNG
            )
            page.set_content(html)                            # inlined CSS, no paths to resolve
            page.locator("#canvas").screenshot(path=out_path)  # crop to the card
    print(f"rendered {out_path}")

if __name__ == "__main__":
    import sys
    render(sys.argv[1], sys.argv[2])                          # render <in.html> <out.png>

With that in place, rendering a real card is a one-time environment setup, then a copy, an edit, and one command:

# one-time setup: create the venv and install Playwright + its Chromium build
python -m venv .venv
.venv/bin/pip install playwright
.venv/bin/playwright install chromium

# copy the type's template, fill it with a real comparison, render to PNG
cp assets/card-template-matrix.html images/card-vocabulary.html
# ...edit images/card-vocabulary.html: set the headline, columns, and rows...

.venv/bin/python scripts/render_image.py \
  images/card-vocabulary.html \
  images/card-vocabulary.png
# expected result: a PNG cropped to #canvas at 2x device scale (the script
# prints "rendered images/card-vocabulary.png" and nothing else)

The filled grid for this very post compares the two ways to grow a card family. The numbers and phrases are the type’s real contract, a .v cell per count and a .vt cell per behavior:

<!-- images/card-vocabulary.html — the .grid, filled with a real comparison -->
<!-- two options here, so two 1fr tracks; no fixed 4-column rule to fight -->
<div class="grid" style="grid-template-columns: 1.3fr repeat(2, 1fr)">
  <div></div>
  <div class="col-h green">Vocabulary</div>
  <div class="col-h pink">Mega-card</div>

  <div class="switch">cost to add a shape</div>
  <div class="dial">Files touched</div>
  <div class="v">1</div><div class="v">3</div>          <!-- new file vs edit template+css+switch -->
  <div class="dial">New conditionals</div>
  <div class="v">0</div><div class="v">1</div>

  <div class="switch">cost of being wrong</div>
  <div class="dial">Blast radius</div>
  <div class="vt">one type</div><div class="vt">every type</div>
</div>

Run it, look at the PNG, and iterate on the source until it reads cleanly cold, because a stranger scrolling a feed will not know any insider units.

The bar for adding versus merging a type

A file-based registry makes adding a type cheap, which is exactly why it needs a bar. The bar is recurrence, not novelty. A shape earns a type once it has shown up enough times to be a real pattern, which is the old rule of three from refactoring practice: let a thing occur about three times before you treat it as a pattern worth extracting (Rule of three). Reaching for an abstraction on the first sighting is how you guess wrong about its shape.

The opposite move is the one that keeps the family honest. The temptation around the fourth type is to fold them all into one mega-card with a flag and a branch per shape, and that is the failure Sandi Metz named: duplication is far cheaper than the wrong abstraction, because the unified component slowly grows a parameter and a conditional for every case until no one can change it safely (The Wrong Abstraction). The merge only makes sense in reverse: when two types turn out to render the same structure, collapse them, and not a release sooner.

That merge condition is the part you can actually approximate. A small test walks the registry and asserts two things: every declared template is fully wired (it has a matching .card.<type> CSS block and a guidance entry, so half-built types cannot ship), and no two templates share an identical class-token sequence (a cheap copy-paste detector, not a true structural oracle: it catches a type that was forked and barely changed, but it will not flag two types that render the same shape under different class names).

# tests/test_card_registry.py — enforce the registry, flag copy-paste duplicates.
# Python 3.9+ for str.removeprefix and the built-in dict[...] / tuple[...] generics.
import re
from pathlib import Path
from itertools import combinations

ASSETS = Path("assets")
CSS = (ASSETS / "diagram.css").read_text()
GUIDE = Path("SKILL.md").read_text()

# cosmetic-only classes: accent colours and the byline marker carry no structure
COSMETIC = {"green", "grey", "pink", "brand"}

def card_types() -> dict[str, Path]:
    # the registry IS the set of template files
    return {p.stem.removeprefix("card-template-"): p
            for p in ASSETS.glob("card-template-*.html")}

def class_signature(html: str) -> tuple[str, ...]:
    # the ordered sequence of class tokens, with nesting discarded. Strip text/ids,
    # split multi-class strings, drop cosmetic accents, and normalize the canvas's own
    # `card <type>` to just `card` so the type name doesn't make every type trivially
    # unique. This is a class-token fingerprint, NOT a parsed DOM tree: two templates
    # that nest the same classes differently will collide, and two that use different
    # class names for the same layout will not. It is a duplicate smell, not a proof.
    sig: list[str] = []
    for attr in re.findall(r'class=["\']([^"\']+)["\']', html):
        tokens = [t for t in attr.split() if t not in COSMETIC]
        if "card" in tokens:        # the canvas element; keep only the shared marker
            tokens = ["card"]
        sig.extend(tokens)
    return tuple(sig)

def test_every_type_is_fully_wired():
    for name, path in card_types().items():
        # match on real selector / filename boundaries so `flow` can't satisfy
        # `flowchart` and a name buried in a CSS comment can't false-pass
        assert re.search(rf"\.card\.{re.escape(name)}\b", CSS), \
            f"{name}: no .card.{name} CSS block"
        assert re.search(rf"card-template-{re.escape(name)}\.html", GUIDE), \
            f"{name}: no SKILL.md guidance"

def test_no_two_templates_share_a_class_signature():
    sigs = {n: class_signature(p.read_text()) for n, p in card_types().items()}
    for (a, sa), (b, sb) in combinations(sigs.items(), 2):
        assert sa != sb, f"{a} and {b} have an identical class sequence; did one fork the other?"

The first test fails the moment a template ships without its CSS or its guidance, which keeps a new word from entering the vocabulary half-defined. The second fails the moment two types carry an identical class sequence, the cheap tell that one was forked from another and barely touched; a real merge still needs a human to confirm the two shapes are the same. So recurrence is what admits a new type, and the duplicate signal is what flags two of them for merging. Anything that is neither stays a small, separate file, and its blast radius is limited to itself.

Next

The library keeps growing as new post shapes call for their own treatment. Each new card has to clear the same recurrence bar rather than a novelty one, and that is what has kept the family small enough to reason about so far.

Sources

Changelog

  • ghostwriter 0.5.0: matrix card type (comparison grid) (03b5c38)