Marketing Surfaces
Live
Mocked at the real 1200×630 aspect ratio using each product's accent — not the actual shipped artwork.
The OG image — what a shared link looks like
The picture that shows up when someone pastes a product's URL into Slack, iMessage, Discord, or a Twitter/X post. Nobody visits it as a page; it exists entirely for the moment a link gets shared, which is also why it is easy to ship nothing and never notice — the gap only shows up in somebody else's chat window, not in your own browser.
The shapes and filenames below are also held in machine-readable form in
tools/og-image-manifest.json, which
tools/check-og-image.mjs enforces. Change a
value in both, in the same commit. Run the checker after touching a product's
<head> or its OG image:
node tools/check-og-image.mjs
Amoleo-Website is the reference implementation. Its card is generated by
scripts/make-og.py from the same theme tokens as the site itself, rather than
designed once by hand and left to rot — regenerate it when the brand tokens
change, the same discipline check-brand.mjs enforces on everything else.
0. Status — who ships what
| Repo | Site | og:image |
|---|---|---|
Amoleo-Website |
www.amoleo.com |
Real. 1200×630, family-listing layout, regenerated 5 Aug 2026. |
Amoleo-Pets |
pets.amoleo.com |
Real. 1200×630, product-wordmark, light background, shipped 5 Aug 2026. |
Amoleo-Connections |
connections.amoleo.com |
Real. 1200×630, product-wordmark, dark background, shipped 5 Aug 2026 — replaced the 512×512 app icon it used to point at. |
Amoleo-Rissbrook |
www.rissbrook.co.uk |
Real. 1200×630, product-wordmark, light background, shipped 7 Aug 2026 (recorded as 5 Aug in error — see manifest note). |
Amoleo-Accounts |
accounts.amoleo.com |
Real. 1200×630, product-wordmark, dark background (this app never sets data-theme, so base.css's own unconditioned default applies), shipped 27 Aug 2026 with a drawn tagline. |
All five products now ship a real card, generated by the one shared script (§3) rather than hand-built ones. Pets, Connections and Rissbrook are still missing a drawn tagline — see §1.5's "open: the tagline" — which isn't a defect, it's a deliberately deferred decision the layout is already built to accommodate. Accounts' card carries one (its mission statement's One-Liner, verbatim) because it was designed after that decision had somewhere to land, not because the question is now settled family-wide.
1. The one size
| CSS/social size | Ratio | |
|---|---|---|
| OG image | 1200 × 630 | ~1.91:1 |
This is the size every major client (Facebook, Slack, iMessage, Discord,
LinkedIn) renders full-bleed under summary_large_image. There is no 2×
variant the way the marketing screenshots have one — link-preview surfaces are
small and compressed hard by the platform regardless, so a sharper source file
buys nothing.
Format: PNG. Scrapers will not render SVG, and most give up on anything that isn't a PNG or JPEG.
Content, briefly: the wordmark, the product name, and enough of the brand
background to be recognisable at thumbnail size — not a screenshot, and not an
app icon centred on a blank field (see Connections, above; a square icon
dropped into a 1.91:1 frame is either heavily letterboxed or cropped,
neither of which anyone chose on purpose). AmoleoWebsite/scripts/make-og.py
is the reference: it draws from the same theme tokens as the CSS, so the card
cannot drift from the brand the way a hand-designed one eventually would.
1.5 Two layouts, not one
Website's card lists the family; every product's card shows only itself.
This isn't a new decision — it's family-wordmark.md §8 applied to a new
surface: "Only the Website has [the multi-product] pattern — it's the one
repo whose page lists more than one product. It isn't expected to appear in
Pets, Connections, or Rissbrook, each of which only ever talks about itself."
An OG card is exactly that kind of surface, so it inherits the same rule.
Amoleo-Website — the family-listing layout, already shipped:
AMOLEO
{tagline}
· Pets · Connections · Rissbrook · Accounts
The family name alone at the top (Website is not itself a product, so there
is no suffix to hang off it), the tagline, and the product list along the
bottom — in lockup.json's order, left to right (see
docs/family-product-order.md). scripts/make-og.py
is the reference implementation.
Every product — the product-wordmark layout:
AMOLEO · PETS
Its own wordmark, hero-sized (family-wordmark.md's markup and colour rule:
the family name in --brand-amoleo, the product name in its own fixed accent
— --brand-pets, --brand-connections, --brand-rissbrook), auto-shrinking
to fit the card (CONNECTIONS is the name that needs it). No family list
— a product card that also lists its three siblings answers a question nobody
asked at the exact moment someone is deciding whether to click through to
this product, and it's a second place the priority order could quietly
drift out of sync with the footer's.
Background is that product's own default theme, not a fixed choice: the
canonical Amoleo Light (#f7f7fb bg, #1c1c2e ink, #6b6b7a muted) or Dark
(#101013 bg, #ffffff ink, #9a9aa8 muted) pair — identical values in
Website's and Connections' own CSS, not a per-product guess. Pets and
Rissbrook are light (Rissbrook has no dark theme at all); Connections is dark.
Accounts has no shipped default yet, so its card waits rather than guessing.
The left-edge stripe is the product's own accent, not the family's neutral one Website's card uses — there's no family row of colours at the bottom of a product's own card to carry the "which product is this" signal, so the stripe carries it instead.
Open: the tagline. Every product's og:description tag already has good
copy, but the card itself doesn't draw any yet — deliberately reserved rather
than assumed, because it isn't settled whether the tagline should be the
product's mission statement One-Liner verbatim or separate copy written for
this card specifically (see docs/mission-statements.md; the One-Liners
themselves vary in voice today — Connections' and Rissbrook's already read
like taglines, Pets' reads as an internal value statement, which is itself
evidence for that decision rather than something to paper over here). The
generator lays out either state correctly: with no tagline, the wordmark
centres as the card's one element; set tagline in the manifest and rerun,
and it moves near the top with the tagline filling the space below, matching
Website's card's own proportions. No layout changes needed either way.
This is a content/layout rule, not something check-og-image.mjs can verify
from pixels — the same limit the screenshot checker has ("whether the shot is
any good" is never checked). It's written down here so a reviewer knows what
"right" looks like, and so nobody designs a future product's card by copying
Website's and swapping the tagline.
2. The five required tags
<meta property="og:image" content="https://pets.amoleo.com/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Amoleo · Pets — free health tracking for cats, dogs, and any other pet.">
<meta name="twitter:card" content="summary_large_image">
og:imagemust be an absolute URL. This is the trap: a relative path (/og.png) is valid HTML and works fine if you load the page yourself, but a scraper fetching the page from its own server has nothing to resolve it against and silently drops the image. It has to be the fullhttps://<product-domain>/…address.:width/:heightmust be declared and must be1200/630. Some clients render before the image has downloaded and use these to reserve space; a missing or wrong pair means a layout jump or, on stricter clients, a card that never renders at all.:altis not decoration. It is what a screen reader announces for the preview card, and what some clients show if the image fails to load. Describe the card in one line, the same discipline as an imagealton the page itself.twitter:cardmust besummary_large_image, notsummary. This is the one that actually decides how much of the image survives:summaryrequests a small square crop from the centre of whateverog:imagepoints at, which is why Connections' square icon "works" today undersummary— a square source into a square crop happens to look intentional — but wastes a real 1200×630 card the moment one exists.summary_large_imageis what renders the image full width.
3. Generating one
Every product-wordmark card comes from one shared script:
tools/make-og-image.py, in this repo.
python tools/make-og-image.py pets
python tools/make-og-image.py connections
python tools/make-og-image.py rissbrook
It reads colours out of tools/tokens/*.tokens.json and layout settings
(background, tagline) out of og-image-manifest.json, and writes straight
into the product's own repo (client/public/og.png or equivalent, resolved
via repos.json's dir mapping — override the root with AMOLEO_ROOT, same
as the checkers). One script, not a reimplementation per repo: make-og.py
(Website's own, family-listing-only) hardcoded its four product colours
directly, and they went stale three days later when the family's brand policy
changed — check-brand.mjs never scans .py files, so nothing caught it
until it was found by hand. Reading tools/tokens/*.tokens.json at generation time
instead of copying its values in means that specific failure can't recur here:
change a colour once, regenerate, done — no fourth place a hex can go stale.
Website's own card stays on its bespoke AmoleoWebsite/scripts/make-og.py —
the family-listing layout is a genuinely different shape (the product list
along the bottom, the order check in check-product-order.mjs) and only ever
has the one instance, so a shared script would add a branch for no reuse.
A hand-designed one-off is acceptable for a first pass on a new product —
better than nothing — but record it as debt: it will not track a token
change, and someone has to remember to update it by hand when the brand does.
Better to add the product to make-og-image.py's reach (a background entry
in the manifest is normally all that's needed) than to hand-design it once.
4. What the checker actually checks
| Rule | |
|---|---|
| R1 | og:image is present and is an absolute https:// URL on the product's own domain |
| R2 | og:image:width is 1200 and og:image:height is 630 |
| R3 | og:image:alt is present and non-empty |
| R4 | twitter:card is summary_large_image |
| R5 | The referenced file exists |
| R6 | The file's actual pixels are exactly 1200×630 |
| R7 | A placeholder/adopted:false product is not asserted as done, and vice versa |
Not checked, and deliberately the same exclusions as the screenshot checker: whether the card looks good, and staleness as a hard failure — a card that still matches the product is still correct at any age.
5. Adopted vs pending
Same idiom as the screenshot manifest and the footer lockup. A product
recorded adopted: false in og-image-manifest.json prints its findings as
pending and does not fail the run — that is recorded follow-up work, not a
regression. Flip adopted to true in the same commit that ships the real
tags and image; a placeholder under an adopted product is exactly the drift
this file exists to catch.
Generated from docs/family-og-image.md + tools/og-image-manifest.json. Edit there and re-run npm run build in docs-site-src/ — never hand-edit a page.