What changed, and why
The per-run share page already unfurls richly: paste a /r/:token link into X or Discord and you get a real card with the run's verdict, objective, and score (that was #88). Everywhere else was bare. The landing page — the most-shared URL, the game itself — pasted as a naked, imageless link. No other public page set an image at all.
This change closes that gap. The landing gets its own Open Graph + Twitter card, and app.vue sets a site-wide default so any public page without its own card still unfurls with the brand image. On top of that come the polish tags the issue asked for: image alt text, og:locale, an apple-touch-icon, a web-app manifest, a canonical link, JSON-LD, and <html lang>.
Blast radius is contained: it's head/metadata plus three new static assets and one small util. No store, no API, no game logic. The one rule that makes or breaks it — og:image and og:url must be absolute, or crawlers drop them silently — is handled in one place and unit-tested.
One source of truth for the brand card
The brand strings and the card's shape live once, in a new utils/site-meta.ts, so the app shell and the landing page can't drift apart. Two helpers carry the logic.
toAbsolute is the load-bearing one. It runs a path against the request origin through the URL parser, so an extra or missing slash can't produce a malformed link — and it can never return a relative URL, which is exactly the failure mode that breaks an unfurl. buildJsonLd returns the schema.org VideoGame object for Google rich results; its return type is the inferred literal, so a dropped or renamed field is a compile error rather than a silent gap. inlineJson serializes that object for the <script> tag, escaping < so no value can break out of the script context.
Brand constants, the absolute-URL guard, the JSON-LD builder, and the inline-script escaper.
utils/site-meta.ts · 81 lines
⋯ 22 lines hidden (lines 1–22)
⋯ 1 line hidden (lines 51–51)
The site-wide default — every page gets the brand card
app.vue is the app shell every page renders inside, so it's where the default lives. It computes the absolute brand image and a clean per-page canonical, reads the optional Twitter handle, then writes two head blocks: the links + JSON-LD via useHead, and the full og/twitter card via useServerSeoMeta.
Absolute URLs up top; links + JSON-LD; then the default og/twitter card.
app.vue · 94 lines
⋯ 39 lines hidden (lines 1–39)
⋯ 1 line hidden (lines 94–94)
The landing card
The landing is the URL people actually share, so it gets an explicit card rather than leaning on the default. The values are the same brand strings — pulled from site-meta.ts, so there's no second copy to drift — but stating them here keeps the landing's unfurl self-contained and legible, and pins og:url to the site root.
The landing's own og/twitter card; title kept separate so two calls don't both write <title>.
pages/index.vue · 203 lines
⋯ 156 lines hidden (lines 1–156)
⋯ 21 lines hidden (lines 183–203)
Config: a gated handle, and lang everywhere
Two config touches. <html lang="en"> is set in app.head rather than app.vue so it reaches routes that bypass the app component — Nuxt's built-in error/404 page — keeping the language declaration consistent everywhere. And the brand's X handle is a runtime config value, defaulting to empty.
lang app-wide; the Twitter handle as opt-in config.
nuxt.config.ts · 95 lines
⋯ 13 lines hidden (lines 1–13)
⋯ 58 lines hidden (lines 20–77)
Reproducible icons, and the guard test
The apple-touch-icon and the manifest's 192/512 icons are real PNGs (iOS and Android don't honor an SVG here). Rather than commit them as mystery binaries, a small committed script renders them from the brand seal mark with resvg — already a dependency — on a full-bleed tile sized to sit inside the maskable safe zone. Rerun it only when the mark changes.
One source SVG → three sizes; the PNGs are committed.
scripts/gen-brand-icons.mjs · 47 lines
⋯ 36 lines hidden (lines 1–36)
⋯ 1 line hidden (lines 47–47)
The test pins the two things that matter most: the one thing that genuinely breaks an unfurl, and the one that's a security hole. It asserts toAbsolute always yields an absolute URL — including the trailing-slash origin edge — and that inlineJson escapes < so a value can't close the script tag (while still round-tripping through JSON.parse).
The absolute-URL guard and the <script>-breakout guard.