Cohesion is leverage: pulling a drifting card family onto one base
Shipped
v0.4.0 pulled Ghostwriter’s image cards back into one family. They had drifted, each type growing its own look over time, so a feed of them no longer read as one brand. Now they sit on the same dark canvas with the same restraint on color: the date card as an ADMIT-ONE ticket, the flow card threaded on a numbered spine, the ramp card as an actual analytics chart with a trend line, the STEM card stripped of its confetti but keeping the chunky blocks. Two new cards landed too: a code card that renders a snippet as a terminal session with line numbers and theme colors, and a Claude Code card that shows a session transcript. The carousel got rebuilt as a portrait 4:5 deck with a progress bar and counter on every slide. I ran it all through a quality gate before merging.
The feature is “the cards look like one product again.” The part worth writing about is the structure underneath it, because a shared base does more than unify the brand: it makes the next card type cheap to add and makes drift something the system rejects rather than something I have to police. Here’s the end-to-end build.
Setup: a base that owns every shared decision
The obvious justification for unifying the cards is “they should look like one brand.” That’s true, and it undersells it. The Nielsen Norman Group defines a design system as a set of standards that manages design at scale by reducing redundancy and creating a shared language and visual consistency (NN/g: Design Systems 101). The operative phrase is reducing redundancy. When every card reinvents its own canvas, spacing, and color choices, each new card type pays the full cost of those decisions again, and the whole set drifts a little further apart every time.
The fix starts with one source of truth. I put the family’s shared values in CSS custom properties, which let a single declaration cascade to every element that reads it (MDN: Using CSS custom properties). The .card base then consumes those tokens and claims the canvas, the surface, the type, and the spacing once, for everyone.
/* cards.css */
:root {
/* One source of truth for the whole family. Cards read these, never raw values. */
--card-w: 1080px;
--card-aspect: 4 / 5; /* portrait, claimed once for every card */
--card-bg: #0a0a0b;
--card-fg: #ededed;
--card-accent: #e6b450;
--card-pad: 64px;
--card-font: ui-monospace, "SF Mono", monospace;
}
/* Every card inherits the canvas, surface, type, and spacing from here. */
.card {
width: var(--card-w);
aspect-ratio: var(--card-aspect);
background: var(--card-bg);
color: var(--card-fg);
font-family: var(--card-font);
padding: var(--card-pad);
box-sizing: border-box;
display: flex;
flex-direction: column;
}
Pulling the family onto this base flips the cost curve. The shared decisions get made exactly once, so a new card inherits the look for free and only has to bring what’s actually unique about its shape.
Build: a card type is just its own deltas
With the base carrying the canvas, a concrete card type becomes small. I followed the BEM convention here: .card is the block, and each type is a modifier like .card--ramp that adjusts the block without redefining it (BEM: Naming convention). The rule I held to is that a modifier may bring its own layout, but it reuses the family’s tokens rather than introducing new raw values.
Here’s the ramp card, the analytics-chart type, in full. It does not restate the background, the size, the font, or the accent color, because those already came down from the base.
/* The ramp card: bars that grow toward a trend. Only its own deltas live here. */
.card--ramp {
justify-content: flex-end; /* anchor the chart to the bottom of the canvas */
}
.card--ramp .card__bars {
display: flex;
align-items: flex-end;
gap: 16px;
height: 60%;
}
.card--ramp .card__bar {
flex: 1;
background: var(--card-accent); /* reuse the family accent; never pick a new color here */
border-radius: 6px 6px 0 0;
}
That is the entire cost of a new card type now: roughly a dozen lines describing one shape. The code card and the Claude Code card landed the same way, each as a thin modifier over the same base, which is why two new types shipped in one release without the set growing inconsistent.
Use it: compose base plus modifier, then render to PNG
A card is authored as the base class plus its modifier, so the markup reads as “a card, of the ramp kind.” The HTML never touches colors or sizes; it only fills in content. .card__title needs little of its own; it inherits color and font-family straight from the base, so a type rule only has to set what’s actually distinct, like size or margin.
<article class="card card--ramp">
<h1 class="card__title">Training load is trending up</h1>
<div class="card__bars">
<div class="card__bar" style="height: 35%"></div>
<div class="card__bar" style="height: 50%"></div>
<div class="card__bar" style="height: 48%"></div>
<div class="card__bar" style="height: 70%"></div>
<div class="card__bar" style="height: 88%"></div>
</div>
</article>
Ghostwriter ships these as images, so the last step is rasterizing that HTML to a PNG at its true pixel size. A headless Chromium does the rendering (a one-time pip install playwright && playwright install chromium), and I clip the screenshot to the .card element so the output is exactly the card box, not the page around it.
# render.py
from pathlib import Path
from playwright.sync_api import sync_playwright
CSS_PATH = Path(__file__).parent / "cards.css" # resolve next to this file, not the CWD
def render_card(card_html: str, out: Path, width: int = 1080) -> None:
"""Rasterize one card's HTML to a PNG at the family's true pixel size.
Renders exactly one card per call: the markup should contain a single
`.card`, and the screenshot is clipped to it.
"""
height = int(width * 5 / 4) # the base's 4:5 aspect, mirrored on the viewport
css = CSS_PATH.read_text() # inline it: set_content has no base URL, so a
doc = ( # relative <link href="cards.css"> would never resolve
'<!doctype html><meta charset="utf-8">'
f"<style>{css}</style>"
f"{card_html}"
)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(doc, wait_until="load")
# .first keeps this from raising strict-mode if the markup ever holds more
# than one .card; one card per call is the contract, so .first is the box we want.
page.locator(".card").first.screenshot(path=str(out)) # clip to the card, not the page
browser.close()
# One card per call; the base owns the dimensions, so there's no per-type sizing here.
card_html = """
<article class="card card--ramp">
<h1 class="card__title">Training load is trending up</h1>
<div class="card__bars">
<div class="card__bar" style="height: 35%"></div>
<div class="card__bar" style="height: 50%"></div>
<div class="card__bar" style="height: 70%"></div>
<div class="card__bar" style="height: 88%"></div>
</div>
</article>
"""
render_card(card_html, Path("ramp.png"))
Every card type goes through this one function. Because the base owns the dimensions, the renderer never needs per-type sizing logic; a new card type plugs into the exact same call.
Verify: the base owns the family, so a type can’t drift
The reason to do this work isn’t only that the cards look unified today. It’s that consistency stops depending on me remembering to be consistent. The weak version of that promise is a comment that says “don’t set colors in a card modifier.” A comment survives exactly until the next person, or the next hurried version of me, ignores it.
The durable version makes drift fail a check. The contract is simple: family-owned properties such as the background, dimensions, type, and base color belong to .card and the tokens; a .card--* modifier that redeclares any of them is drifting by definition. A small test parses the stylesheet and enforces exactly that.
# tests/test_card_family.py
import re
from pathlib import Path
# Resolve relative to this test file, and assume flat (non-nested) CSS: the
# rule regex below splits on top-level braces, so a modifier buried inside an
# @media {...} block would be missed. Keep the family rules un-nested.
CSS = (Path(__file__).parent.parent / "cards.css").read_text()
# Decisions the base alone is allowed to make for the whole family. These are
# shorthands; the check below also matches their longhands by prefix, so
# `background-color` or `inline-size` can't slip past the shorthand list.
FAMILY_OWNED = {
"background", "background-image",
"width", "inline-size", "block-size",
"aspect-ratio", "font-family", "color",
}
def _is_owned(prop):
return any(prop == owned or prop.startswith(owned + "-") for owned in FAMILY_OWNED)
def _rules():
for selector, body in re.findall(r"([^{}]+)\{([^}]*)\}", CSS):
# Strip comments before splitting: a `;` inside a /* ... */ comment would
# otherwise mangle the property name of the declaration that follows it.
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
props = {p.split(":", 1)[0].strip() for p in body.split(";") if ":" in p}
yield selector.strip(), props
def test_modifiers_never_redeclare_family_props():
"""A card type may define its own layout; it may not redefine the canvas."""
for selector, props in _rules():
# Only judge rules whose SUBJECT is the card box itself: `.card--x`,
# `.card.card--x`, and pseudo/chained subjects like `.card--ticket::before`
# or `.card--ticket:hover` (a perforation pseudo-element is a natural place
# to sneak in a background). A descendant like `.card--ramp .card__bar` is a
# separate, space-delimited element legitimately reading a family prop, so
# skip it; otherwise the bars' own `background` would trip the guard.
if not re.search(r"\.card--[\w-]+(?:::?[\w-]+|\.[\w-]+|\[[^\]]*\])*\s*$", selector):
continue
drift = {p for p in props if _is_owned(p)}
assert not drift, (
f"{selector} redeclares family-owned {sorted(drift)}; "
f"inherit it from .card instead of overriding it."
)
This is the principle of least privilege applied to CSS: a modifier gets only the power to describe its own shape, not the power to redefine the family. The moment someone adds background-color: #112233 to a card type to make it “pop,” the test goes red and points at the selector; the prefix match means the longhand is caught just like the background shorthand, and the subject match catches it on a pseudo-element too, so painting a background through .card--ticket::before doesn’t slip past. The base owns the look, and the check makes that ownership real instead of aspirational.
The carousel followed the same discipline
The carousel got the most visible work, and I did the unglamorous thing first: I read what the format rewards before restyling. NN/g’s first usability heuristic is visibility of system status: a design should always keep users informed about what is going on, through appropriate feedback within reasonable time (NN/g: Visibility of System Status). For a multi-slide deck, “what is going on” is mostly “where am I in the sequence,” and NN/g’s guidance on progress indicators makes that concrete: when a percentage isn’t meaningful, show the number of steps and indicate the current one so people can form an estimate of what’s left (NN/g: Progress Indicators). So every slide now carries a progress bar and a counter that say where you are in the deck, the cover stands apart to set the frame, and the deck ends on one clear ask. The slides are still cards on the same base; the progress affordance is one more modifier, not a separate styling universe.
Next
The portrait sizing and the progress affordance are worth carrying back into the single-image formats, so the whole output reads as one system rather than a carousel plus some loose cards. Because the family is consistent now, that kind of change propagates from the base and the tokens instead of being reapplied card by card, and the family test keeps any new format from quietly forking the look.
Sources
- Nielsen Norman Group: Design Systems 101 — a design system reduces redundancy and creates a shared language and visual consistency.
- MDN: Using CSS custom properties — one declaration cascades to every element that reads the variable, giving the family a single source of truth.
- BEM: Naming convention — block plus modifier, so a card type adjusts the base block without redefining it.
- Nielsen Norman Group: Visibility of System Status — keep users informed about what is going on through appropriate feedback within reasonable time.
- Nielsen Norman Group: Progress Indicators — when a percentage isn’t meaningful, show the number of steps and indicate the current one so users can estimate what remains.