## Why

An invitation link is shared far more often than it is sent. A guest forwards theirs to a
spouse, an admin pastes one into WhatsApp for a guest with no email address, and every
invitation email puts a bare URL in front of someone who has to decide whether it is real.
Today all of those render as naked text — `https://…/i/Xk3…` — which reads as spam for a
message that is asking someone to commit an evening and a seat at a catered table.

The event already has artwork worth showing. This change puts a picture and a sentence on
the link wherever the link appears, without changing what the link discloses.

## What Changes

- **An event's published card design gains an OG image** — one uploaded 1200×630 raster,
  stored beside the card's other companion files, versioned and published with it. It is
  uploaded rather than derived: the card is portrait HTML in a shadow root, and a faithful
  screenshot of it centre-crops the couple's names out of the frame at thumbnail size.
- **`/i/{token}` and `/e/{slug}` emit Open Graph and Twitter Card meta tags.** The tags are
  **identical for every guest on the event** and carry no guest data — same title, same
  description, same image on both routes. `og:url` is canonical to `/e/{slug}`. `noindex`
  and `no-referrer` on the tokenized route are unchanged.
- **Invitation emails carry a rendered preview block.** Mail clients do not read OG tags at
  all, so the block is HTML we compose ourselves and place above the message body: image,
  event title, date and venue, and the guest's own link as the anchor. This block *is*
  personalized — it is sent to one inbox and no crawler ever sees it.
- **The admin compose panel shows that same preview** before sending, and gains a
  copy-to-clipboard control for the guest's token link so an admin can hand it to someone
  over WhatsApp without retyping it.
- **A shared preview model** computes title, description, image and URL once, per surface,
  so the three renderings cannot drift into describing the event differently.

Not in scope: rasterizing the card HTML with a headless browser, generating images from
event data, per-guest preview images, and OG tags on any admin route.

## Capabilities

### New Capabilities

- `link-preview`: The preview model itself — what an invitation preview contains, the OG
  image asset and its format and size constraints, which surfaces may personalize it, and
  how every surface degrades when an event has no image.

### Modified Capabilities

- `invitation-card`: A card design may carry a designated OG image alongside its companion
  files; it uploads, versions, publishes and rolls back with the design rather than
  separately.
- `invitation-page`: `/i/{token}` and `/e/{slug}` emit link-preview meta tags. New
  requirement that the tokenized route's tags are guest-free and public-equivalent, which
  extends rather than reverses the existing rule that the guest's name stays out of the
  page title.
- `messaging`: Outgoing invitation emails carry a preview block above the body; the admin
  compose panel renders the same preview and offers the guest's token link for copying.

## Impact

**API**
- `api/app/routers/admin_cards.py` — a dedicated `og_image` upload field on the design
  upload; the OG image is stored under a reserved asset key so a companion file named
  `og.png` cannot claim it.
- `api/app/services/media.py` — a second validation path for preview images: raster only
  (PNG/JPEG), a hard byte cap, and minimum dimensions parsed from the file header. Stays the
  only module that touches the media filesystem.
- New `api/app/services/link_preview.py` — builds the preview model for a given event and
  optional invitation. One source for the meta tags, the email block and the admin panel.
- `api/app/services/templating.py` — `to_html` accepts a preview block. Its current
  escape-then-linkify pass assumes the whole body is plain text, so the block has to be
  composed from already-escaped values and inserted, not concatenated into the input.
- `api/app/schemas/invitation.py` and the open-event and invitation-by-token responses gain
  a preview object, which regenerates `web/lib/api/schema.d.ts`.

**Web**
- `web/app/i/[token]/page.tsx` and `web/app/e/[slug]/page.tsx` — `generateMetadata` emits
  the tags; needs `metadataBase`, so `web/lib/public-config.ts` gains an `appBaseUrl()`
  accessor reading an un-prefixed runtime variable.
- `web/components/admin/InviteCompose.tsx` and a new shared preview component.
- `web/lib/view-models.ts` — the preview is another explicit view model, not a spread.

**Config / ops**
- `APP_BASE_URL` reaches the web container (the API already has `app_base_url`); absolute
  OG URLs are meaningless without it, and a wrong value silently produces broken previews.
- Preview images live under `media/`, so the existing backup rule already covers them.

**Deliberately unaffected**: the message log (the preview block carries the token, so it is
built at send time and never stored), the idempotency key, quiet hours, suppression, and
every reminder and wave path.
