An attribution mark the common crops can't remove: mark every region a reshare keeps
Shipped
The LinkedIn ghostwriter renders posts as cards, and this release added a new card type. The ramp card draws three ascending steps with the final one highlighted, for posts about an accelerating progression like a growth curve, an adoption trend, or a compounding streak. It had been stranded on an unmerged branch from before the skills were consolidated, so bringing it into the monorepo meant a PR with the scorer and full test suite green. I also ran the skill end to end for the first time, drafting and publishing a real post with the new card.
That live run is where the interesting problem showed up, and it had nothing to do with the cards themselves. It was attribution: when a designed card gets reshared, how do you keep your name on it? The part worth writing about is how a signature survives a crop, and the rest of this post is the end-to-end build, from the card template through the rendered PNG to a check that the mark actually holds.
Setup: the card template
A card is just an HTML document the renderer will later turn into an image. Before the attribution work, the structure needs three regions: an eyebrow row at the top, a headline, and a body that holds the figure or copy. Keeping these as named regions matters, because the attribution fix is going to change the eyebrow from a single label into a two-sided row, and everything downstream should keep working when it does.
<!-- card.html — the base template, one fixed-size canvas the renderer screenshots. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
:root { --ink: #14171a; --muted: #5b6770; --accent: #2f6df0; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, system-ui, sans-serif; color: var(--ink); }
.card { position: relative; width: 1200px; height: 1200px; padding: 72px; display: flex; flex-direction: column; }
.card-eyebrow { /* filled in by the signature step */ }
.card-headline { font-size: 64px; line-height: 1.1; margin: 24px 0 0; }
.card-body { flex: 1; display: flex; align-items: center; }
</style>
</head>
<body>
<main class="card">
<header class="card-eyebrow"><!-- headline + signature go here --></header>
<section class="card-body"><!-- ramp figure or copy goes here --></section>
</main>
</body>
</html>
The fixed 1200px square is the LinkedIn-friendly canvas the renderer captures. Everything from here builds inside the eyebrow and the body.
Build the signature into the headline row
The obvious approaches both fail. A byline in the bottom corner is trivial to crop off; anyone can screenshot the card minus the last strip and the attribution is gone. A tiled watermark across the whole card survives cropping, but it looks terrible over a real design and undercuts the thing it’s supposed to protect.
The fix was to stop treating the signature as a decoration that sits in spare space and start treating it as part of the content a reshare keeps. A card has two regions worth keeping, the headline and the figure, so I marked both. I put a copy of the avatar and handle at each end of the headline row and let the headline fill the space between them, so a mark sits at both horizontal extremes of the line nobody wants to lose; and I dropped the handle into the figure’s own note, so the chart that gets reshared carries its attribution with it. Now the lazy crops each leave a mark: a bottom-strip crop keeps the top row with both bracketing marks, a top-strip crop that drops the eyebrow still keeps the figure and its handle, and either single-side crop of the headline leaves a mark on the far end. Be clear about the limit, though: no pure-CSS layout resists an arbitrary rectangular crop, because a determined editor can always cut a tight window around one region and drop every mark around it. What this defeats is the lazy reshare, the single straight strip people actually make, by putting a mark on each region a reshare needs to keep.
This matters because resharing is exactly when provenance gets destroyed. The metadata-based answer to “who made this” is the cryptographic kind, like C2PA Content Credentials, but the C2PA spec itself is candid that it “does not offer any protection against the complete removal of C2PA manifests,” and names the obvious attack: download a credentialed image from a social site, strip the manifest, and re-post it (C2PA: Security Considerations). Platforms recompress and re-encode uploads as a matter of course, so the content most likely to go viral is the content most likely to arrive stripped of its provenance, which is why C2PA leans on manifest repositories and soft bindings to recover what embedding alone can’t guarantee (World Privacy Forum: Privacy, Identity and Trust in C2PA). A visible, crop-resistant mark is the layperson’s version of the same goal: provenance that travels with the pixels instead of with metadata a re-upload will quietly drop.
The technique is small. Lay the eyebrow out as a flex row, give the headline flex: 1 so it fills the middle, and place a signature on each side.
<!-- Goes inside .card-eyebrow. A signature sits at each end of the headline's
row, so a bottom-strip crop and either single-side crop all keep a mark. -->
<a class="card-sig" href="https://www.linkedin.com/in/yourhandle">
<img class="card-sig__avatar" src="avatar.jpg" alt="" />
<span class="card-sig__handle">@yourhandle</span>
</a>
<h1 class="card-headline">Three releases, one compounding streak</h1>
<a class="card-sig" href="https://www.linkedin.com/in/yourhandle">
<span class="card-sig__handle">@yourhandle</span>
</a>
<style>
.card-eyebrow {
display: flex;
align-items: center;
gap: 24px;
}
.card-headline { flex: 1; margin: 0; } /* headline fills the space between the two marks */
.card-sig { display: flex; align-items: center; gap: 12px; flex-shrink: 0; text-decoration: none; color: var(--muted); }
.card-sig__avatar { width: 56px; height: 56px; border-radius: 50%; }
.card-sig__handle { font-weight: 600; white-space: nowrap; }
</style>
A flex row with a flexible middle child is the whole idea: the headline stretches between two marks pinned to the ends (MDN: Basic concepts of flexbox). The flex-shrink: 0 on each signature keeps the avatars and handles from collapsing when a long headline competes for width, so the marks stay legible at both ends instead of degrading into the corner case they were meant to avoid.
Build an honest ramp figure
The ramp card draws bars in the body, and the bars are drawn to scale: each height is proportional to the figure it represents, and the labeled figures next to them carry the exact numbers. That was a conscious call, because a bar chart whose heights don’t track the quantities is one of the most reliable ways to mislead an audience by accident.
The risk is well documented. When a bar’s size doesn’t track the quantity it represents, viewers still read it as if it did, and the misreading is stubborn. Research on misleading bar magnitudes makes the point: a study of axis-truncated bar graphs found the distortion persisted even after participants were explicitly taught about the effect, showing up in 83.5% of participants across five studies (Truncating Bar Graphs Persistently Misleads Viewers). The mechanism is the same whether the axis is truncated or the heights are simply not proportional: people trust the shape over the caption.
The honest move is to make the heights track the numbers and then label the exact figures, so the shape and the caption tell the same story (data.europa.eu: Honest charts). The bar heights here are proportional to the figures: 1, 4, and 9 posts render as 11%, 44%, and 100% of the tallest bar, set as fixed inline heights, and the same figures appear in the labels next to each step. Anyone who reads the numbers gets the truth, and the shape they trust at a glance agrees with it. The attribution note is on the card itself rather than buried in a caption nobody screenshots, and it carries the handle, so a crop that keeps the chart and drops the eyebrow keeps a mark.
<!-- Goes inside .card-body. Heights are proportional to the figures (1/4/9 -> 11/44/100%); labels carry the exact numbers. -->
<figure class="ramp" aria-label="Ascending progression, bars proportional to the figures">
<div class="ramp__bar" style="height: 11%"><span class="ramp__label">v0.1 · 1 post</span></div>
<div class="ramp__bar" style="height: 44%"><span class="ramp__label">v0.2 · 4 posts</span></div>
<div class="ramp__bar ramp__bar--hero" style="height: 100%"><span class="ramp__label">v0.3 · 9 posts</span></div>
<figcaption class="ramp__note"><span class="ramp__sig">@yourhandle</span> · Bars proportional to the figures.</figcaption>
</figure>
<style>
.ramp { display: flex; align-items: flex-end; gap: 32px; width: 100%; height: 100%; margin: 0; }
.ramp__bar { flex: 1; background: #d7dee8; border-radius: 16px 16px 0 0; display: flex; align-items: flex-end; justify-content: center; }
.ramp__bar--hero { background: var(--accent); } /* highlight the final step */
.ramp__label { padding: 12px; font-size: 22px; font-weight: 600; color: var(--ink); }
.ramp__note { position: absolute; bottom: 72px; left: 72px; font-size: 18px; color: var(--muted); }
.ramp__sig { font-weight: 600; } /* the figure's own attribution mark */
</style>
The bars read as ascending in exactly the proportion the labels state, so the first step really is one-ninth the height of the last. The visual reads as honest because the shape and the numbers agree.
The assembled card
The base template, the bracketed signature row, and the ramp figure are one file. This is the complete card.html the renderer and the test below both take as input.
<!-- card.html — base template, signature row, and ramp figure assembled into one file. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
:root { --ink: #14171a; --muted: #5b6770; --accent: #2f6df0; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, system-ui, sans-serif; color: var(--ink); }
.card { position: relative; width: 1200px; height: 1200px; padding: 72px; display: flex; flex-direction: column; }
.card-eyebrow { display: flex; align-items: center; gap: 24px; }
.card-headline { flex: 1; margin: 0; font-size: 64px; line-height: 1.1; }
.card-sig { display: flex; align-items: center; gap: 12px; flex-shrink: 0; text-decoration: none; color: var(--muted); }
.card-sig__avatar { width: 56px; height: 56px; border-radius: 50%; }
.card-sig__handle { font-weight: 600; white-space: nowrap; }
.card-body { flex: 1; display: flex; align-items: center; }
.ramp { display: flex; align-items: flex-end; gap: 32px; width: 100%; height: 100%; margin: 0; }
.ramp__bar { flex: 1; background: #d7dee8; border-radius: 16px 16px 0 0; display: flex; align-items: flex-end; justify-content: center; }
.ramp__bar--hero { background: var(--accent); }
.ramp__label { padding: 12px; font-size: 22px; font-weight: 600; color: var(--ink); }
.ramp__note { position: absolute; bottom: 72px; left: 72px; font-size: 18px; color: var(--muted); }
.ramp__sig { font-weight: 600; }
</style>
</head>
<body>
<main class="card">
<header class="card-eyebrow">
<a class="card-sig" href="https://www.linkedin.com/in/yourhandle">
<img class="card-sig__avatar" src="avatar.jpg" alt="" />
<span class="card-sig__handle">@yourhandle</span>
</a>
<h1 class="card-headline">Three releases, one compounding streak</h1>
<a class="card-sig" href="https://www.linkedin.com/in/yourhandle">
<span class="card-sig__handle">@yourhandle</span>
</a>
</header>
<section class="card-body">
<figure class="ramp" aria-label="Ascending progression, bars proportional to the figures">
<div class="ramp__bar" style="height: 11%"><span class="ramp__label">v0.1 · 1 post</span></div>
<div class="ramp__bar" style="height: 44%"><span class="ramp__label">v0.2 · 4 posts</span></div>
<div class="ramp__bar ramp__bar--hero" style="height: 100%"><span class="ramp__label">v0.3 · 9 posts</span></div>
<figcaption class="ramp__note"><span class="ramp__sig">@yourhandle</span> · Bars proportional to the figures.</figcaption>
</figure>
</section>
</main>
</body>
</html>
The .card carries position: relative so the absolutely positioned .ramp__note anchors to the card rather than the viewport. One coupling to know about: the note’s bottom: 72px is deliberately the card’s 72px padding, which is what lands the mark inside the body’s vertical band where the crop test checks for it. If you change the card padding without changing this offset, the mark drifts out of that band and the crop test below correctly fails; keep the two in sync, or anchor the note inside the figure’s own flow instead. Swap the @yourhandle strings, the href, and avatar.jpg for your own.
Use it: render the card to a PNG
The card is HTML, and a post needs an image to attach. A headless browser renders the markup exactly as a browser would and screenshots it, which keeps the flex layout and fonts faithful rather than re-implementing them in an image library. Playwright’s screenshot captures a page or a single element to a PNG (Playwright: Screenshots).
Install Playwright and its browser once:
pip install playwright && playwright install chromium
# render.py — turn a card HTML file into a PNG ready to attach to a post.
from pathlib import Path
from playwright.sync_api import sync_playwright
def render_card(html_path: str, out_path: str, size: int = 1200) -> str:
with sync_playwright() as p:
browser = p.chromium.launch()
# Match the .card canvas so the screenshot has no scrollbars or letterboxing.
page = browser.new_page(viewport={"width": size, "height": size},
device_scale_factor=2) # 2x for crisp text
page.goto(Path(html_path).resolve().as_uri())
page.wait_for_load_state("networkidle") # let the avatar image settle
page.locator(".card").screenshot(path=out_path) # screenshot the card element only
browser.close()
return out_path
if __name__ == "__main__":
render_card("card.html", "post.png")
Screenshotting the .card locator rather than the whole page means the output is exactly the square canvas, with the marks bracketing the headline as laid out. With device_scale_factor=2 the file lands at 2400px on a side, twice the 1200px CSS canvas, so the text stays crisp after LinkedIn recompresses it; drop the factor to 1 if you want a 1200px file instead. That PNG is what gets uploaded with the post.
Verify the mark survives a crop
The claim is that the straight-strip crops people actually make can’t take a region without taking a mark, and that is testable. Three facts should hold. The two headline marks each sit inside the headline’s vertical band, so a top- or bottom-strip crop that keeps the headline keeps them. One headline mark sits on either side of the headline, so a single left- or right-edge crop that keeps the headline still leaves a mark on the far end. And the figure carries its own mark inside the body’s band, so a top-strip crop that drops the eyebrow but keeps the chart still keeps attribution. Read the bounding boxes from the same render and assert all three, so the layout can’t silently drift back into a single croppable corner.
# test_signature.py — fail if a single straight strip could take a region without a mark.
from pathlib import Path
from playwright.sync_api import sync_playwright
TOL = 4 # px of slack for sub-pixel rounding and vertical centering
def contains_vertically(outer: dict, inner: dict, tol: float = TOL) -> bool:
# inner's whole vertical band sits within outer's, so any strip that keeps
# outer also keeps inner (overlap > 0 isn't enough: a centered mark in a
# tall row can still be sheared off by a strip that keeps one edge).
return (inner["y"] >= outer["y"] - tol
and inner["y"] + inner["height"] <= outer["y"] + outer["height"] + tol)
def test_every_region_keeps_a_mark():
url = Path("card.html").resolve().as_uri()
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 1200})
page.goto(url)
page.wait_for_load_state("load") # let layout and the avatar settle
head = page.locator(".card-headline").bounding_box()
body = page.locator(".card-body").bounding_box()
marks = [m.bounding_box() for m in page.locator(".card-sig").all()]
fig_mark = page.locator(".ramp__sig").bounding_box()
browser.close()
assert len(marks) >= 2
# Each headline mark is contained in the headline's band -> a top- or
# bottom-strip crop that keeps the headline can't shear a mark off it.
assert all(contains_vertically(head, m) for m in marks)
# A mark left of the headline -> survives a right-edge crop that keeps the headline.
assert any(m["x"] < head["x"] for m in marks)
# A mark right of the headline -> survives a left-edge crop that keeps the headline.
assert any(m["x"] + m["width"] > head["x"] + head["width"] for m in marks)
# The figure carries its own mark inside the body band -> a top-strip crop
# that drops the eyebrow but keeps the chart still keeps attribution.
assert contains_vertically(body, fig_mark)
If someone moves a signature out of the headline row, its band stops being contained in the headline’s and the test fails; if someone drops the second copy, the bracketing assertions fail; if the figure loses its handle, the body-band assertion fails. The structural guarantee, that a mark rides on every region a reshare keeps, becomes a regression test instead of a note in a design doc.
Next
I want more reps of the skill running end to end on real posts, since the first live run is what surfaced both the signature approach and a round of fluff-word trimming. The card library grows from what real posts actually need, so the next templates will come from drafts rather than from guessing at categories up front.
Sources
- C2PA: Security Considerations — the spec admits manifests can be fully removed by re-uploading and re-posting, motivating other bindings.
- World Privacy Forum: Privacy, Identity and Trust in C2PA — technical review of content provenance and why metadata-based attribution is fragile in distribution.
- Truncating Bar Graphs Persistently Misleads Viewers — viewers misread non-proportional bars even after being taught about the effect.
- data.europa.eu: Honest charts — accurate data isn’t enough; labels and annotations are the safeguard against misleading visuals.
- MDN: Basic concepts of flexbox — a flex row with a
flex: 1middle child holds the headline between two marks pinned to the ends. - Playwright: Screenshots — capturing a page or a single element to a PNG with a headless browser.
Changelog
- feat(ghostwriter): add ramp card type for accelerating progressions (#1) (c2c6b2b)