# Share cards (Open Graph / Twitter)

A demo's most common first impression is not the site. It is a link pasted into a chat,
a WhatsApp thread or a LinkedIn post while someone says "look at this one." A demo that
unfurls as a grey box with a truncated URL has failed before it is opened — and the two
sites that shipped without cards were being *shared by the agency itself*, which is the
worst possible place to look unfinished.

So every site gets **one card per route**, and every card carries the attribution.

## The shape

- **1200×630, PNG.** Every crawler agrees on that size, and 1.91:1 is what Facebook,
  LinkedIn, Slack and X all crop to. Anything else gets cropped by somebody.
- **PNG, not WebP**, even though the rest of the site is WebP. X and Slack render WebP
  cards; LinkedIn and several link-preview bots still do not, and a card that fails to
  render costs far more than the ~40 KB saved.
- **Drawn as SVG, rasterized once, committed.** Not generated at request time, not
  generated on the deploy runner — see "Committed artifact" below.

## Content of a card

Four zones, and the discipline is that none of them is invented here:

| Zone | Source |
| --- | --- |
| Brand mark + wordmark | **Inlined from the logo SVGs**, not redrawn |
| Title | The page's real `<title>`, with the site-name suffix stripped |
| Description | The page's real `<meta name="description">` |
| Footer strip | The pitch URL, and the attribution line |

The attribution is not optional and not fine print:

> A demo by AlphaBlue · Creative direction by Luke

This is the footer contract reaching the one place the footer cannot: a link pasted into
a chat is seen by people who will never scroll to the bottom of the page, and often never
click at all. If the credit is going to survive anywhere, it has to survive here.

**The left side of that strip is the pitch URL — a fixed string, identical on every site
in the family:**

> `https://start.alphablueth.com`

It used to be the site's own hostname, derived from `site`. That was wrong twice over.
It spent the card's only spare line telling readers the address they were already looking
at, and it made the host a *baked* value — so every domain change silently invalidated
every card, with nothing in the toolchain to catch it (the staleness guard compares titles
and descriptions, never the host). A constant fixes both: the line now says *where you get
one of these*, and cards stop being coupled to hosting at all.

Keep it a literal in the card script, next to `ATTRIB`. Do not thread it through
`cards.json`, and do not derive it from anything — the whole value is that it cannot drift.

**Inline the mark and wordmark from the generated logo SVGs** — read the file, lift the
inner markup, wrap it in a `<g transform="translate(…) scale(…)">` computed from its
`viewBox`. Redrawing the mark inside the card script means two copies of the geometry and
a card that silently stops matching the logo the first time the logo is revised.

## Derive the cards from the built HTML, not from a list

The temptation is a hand-written list of routes and titles in the card script. Do not.
Walk `dist/**/index.html` after the build and read each page's actual `<title>` and
description. This buys three things at once:

- Dynamic routes are covered for free — a `[slug]` page that generates eight units
  generates eight cards, with no second place to remember to update.
- A card can never disagree with the page it represents.
- The content model stays the single source of truth, one more link down the chain.

## Committed artifact, guarded

The card toolchain is Python + fontTools + librsvg. **The deploy runner has none of
them**, and adding them to the runner to draw an image that changes twice a year is the
wrong trade. So:

- `pnpm og:build` runs **locally** and writes `public/og/<slug>.png`, which is committed.
- `pnpm build` runs a **check** script — pure node, no toolchain — that fails on a
  missing card, a card that is not a 1200×630 PNG, a page missing `og:image` / `og:url` /
  `twitter:card` / canonical, or a card whose baked title or description **no longer
  matches the page**.

That last one is the whole reason the check exists. A committed artifact generated from
copy will go stale the first time the copy is edited, and nothing else in the pipeline
would ever notice. Store what was baked (`assets/og/cards.json`) and compare on every
build.

The ordering is deliberate and worth stating in the script's header, because the first
run looks like a failure:

```
pnpm build      # dist/ exists; the check fails — no cards yet
pnpm og:build   # reads dist/, draws the cards
pnpm build      # check passes
```

## The head tags

`og:title` / `og:description` / `og:type` alone — which is what a default scaffold gives
you — produce no image at all. The full set:

```
<link rel="canonical" href={canonical} />
og:title, og:description, og:type, og:url, og:site_name, og:locale
og:image, og:image:type, og:image:width, og:image:height, og:image:alt
twitter:card = summary_large_image, twitter:title, twitter:description, twitter:image
```

**`og:image` must be an absolute URL.** A root-relative `/og/home.png` looks completely
correct in the markup and resolves to nothing at every crawler — this is the classic
silent failure. Build it with `new URL(path, Astro.site)`, which also means `site` must
be set in `astro.config.mjs`.

Put `og:image:alt` to use: it is the one alt text that describes the *demo*, so let it
carry the attribution too.

## On a bilingual site

**One card per route per locale**, drawn from that locale's own built HTML — the whole point
of deriving from `dist/` is that the card cannot disagree with its page, and a shared card
across locales breaks exactly that. Ten cards for five routes is the normal count.

Three things then stop being free, and the first is a correctness bug rather than a
typographic nicety.

- **Mark positioning — `typeset.py` does not shape, and the failure is silent.** It lays
  glyphs out by advance width alone. That is correct for Latin and wrong for every script
  that positions marks with GPOS. Combining marks have **zero advance**, so every mark in a
  cluster lands on the same pen position: Thai `ที่` is ท + ี + ่, the tone mark has to sit
  above *the vowel*, and without shaping the two draw at the same height and the tone mark
  disappears inside the vowel. Measured on a real card: the two marks' min-y came back
  `-81.7` and `-85.0`. Nothing errors. The word just quietly loses its tone.

  Copy [`../assets/shape.py`](../assets/shape.py) in and route the card script's `layout`
  and `advance` through it — HarfBuzz shapes from the font binary, fontTools draws from the
  glyph set, so it needs both. After shaping, `-81.7` and `-110.1`.

  **This scales far past Thai.** Arabic needs joining forms, Devanagari and Khmer reorder,
  Lao and Burmese stack. Any of them rendered through advance-width layout is broken in a
  way that looks like a font problem and is not. Add the dependency to the venv; only
  `pnpm og:build` uses it, it runs locally, and its output is committed — the deploy runner
  never sees it.

- **Line breaking.** `typeset.wrap()` breaks on spaces, and Thai does not write them, so a
  Thai title lays out as one line that runs off the card — no error, no warning. The shipped
  `assets/typeset.py` breaks by orthographic cluster for exactly this reason; ellipsizing has
  to follow the same rule or a mark is left stranded from its consonant.

  Cluster breaking never overruns, but it will split a word and strand one character on the
  next line. **Thai does space its phrases even though it does not space its words**, so
  break at spaces first and fall back to clusters only when a single space-delimited run is
  itself wider than the column. Proper word breaking needs a dictionary; this is the 90%.

- **Leading.** Thai stacks vowels above and tone marks above those, so lines set at the
  Latin leading collide. Scale it per script (~1.4× on the display size, ~1.75× on the
  description worked) rather than loosening both, and drop the display face's negative
  tracking for Thai — tightening a script that positions marks over its consonants pushes
  them out of alignment.

## Traps

- **The slug rule lives in two places** — the layout (which emits the `og:image` URL at
  build time) and the card script (which names the file). They cannot import each other
  across the Astro/node boundary, so write the rule in one line, comment each side with a
  pointer to the other, and let the check catch a divergence.
- **The site-name suffix strip is a literal comparison.** If the data file has a straight
  apostrophe and the wordmark a curly one, matching against the logo's spelling silently
  strips nothing and every card says the brand name twice. Match the **data file**.
- **Decode HTML entities** before drawing. The built `<title>` contains `&#39;` and
  `&amp;`; drawn literally they appear as-is on the card.
- **Guard glyph coverage before measuring, not while drawing.** The width functions look
  glyphs up too, so an uncovered character fails first inside the wrapper with a bare
  `KeyError: 3647` rather than a message naming the font and the character. Run the
  coverage check on every string at the top of the card function. This is the same
  currency-glyph trap as the display font — a title containing `฿` will hit it.
- **Fit the type, do not fix it.** Titles vary from one word to twelve. Try display sizes
  largest-first and take the first that wraps within the line budget, so a short title is
  not punished for a long sibling. Ellipsize the description rather than let it collide
  with the footer rule.

## Verify by looking

Rasterize and **open the PNGs**. Check the longest title and the shortest, and at least
one dynamic route. Also confirm the card is legible at thumbnail size — most feeds show
it at roughly 400px wide, where a 54px title is 18px.
