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
utils/site-meta.ts81 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1/**
2 * Brand identity + the default social card — the single source of truth shared by the
3 * app shell (the site-wide default, `app.vue`) and the landing page (`pages/index.vue`),
4 * so the brand strings and the card's shape live in exactly one place. A second copy is
5 * one edit away from drifting (issue #118).
6 *
7 * The per-run share page (`pages/r/[token].vue`, issue #88) already sets its own complete
8 * og/twitter block pointed at the live per-run PNG; this module is the DEFAULT every other
9 * public page falls back to — the brand card (`public/og-default.png`).
10 *
11 * Absolute-URL rule: `og:image` / `og:url` MUST be absolute to unfurl — crawlers silently
12 * ignore relative URLs. So the helpers take an `origin` and return absolute URLs. Callers
13 * get the origin from `useRequestURL()` — the real SSR request origin the crawler hit, the
14 * window origin on the client — exactly as the share page already does.
15 */
16 
17/** The brand name. Used for `og:site_name` and as the schema.org game name. */
18export const SITE_NAME = 'Everwhen'
19 
20/** The default browser title + og/twitter title for a page without its own. */
21export const SITE_TITLE = 'Everwhen — Rewrite History'
22 
23/** The default meta + og/twitter description (the landing's hook copy). */
24export const SITE_DESCRIPTION =
25 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.'
26 
27/** og:locale — the brand ships in US English today. Pairs with `<html lang="en">`. */
28export const OG_LOCALE = 'en_US'
29 
30/** The committed brand card (`public/og-default.png`), 1200x630. Relative — absolutized per request. */
31export const OG_DEFAULT_IMAGE_PATH = '/og-default.png'
32export const OG_IMAGE_WIDTH = 1200
33export const OG_IMAGE_HEIGHT = 630
34export const OG_IMAGE_TYPE = 'image/png'
35 
36/**
37 * Accurate alt text for `og-default.png` — surfaced by some platforms and read by screen
38 * readers, so it describes what the card actually shows (a wax-seal astrolabe + wordmark).
39 */
40export const OG_IMAGE_ALT =
41 'The Everwhen brand card: a terracotta wax-seal astrolabe above the wordmark “Everwhen” and the line “Every message rewrites history.”'
42 
43/**
44 * Absolute URL for a public path against the request origin. Uses the URL parser so an
45 * extra/missing slash can't produce a malformed link. `toAbsolute(origin, '/og-default.png')`
46 * → `https://everwhen.example/og-default.png`; `toAbsolute(origin, '/')` → `https://everwhen.example/`.
47 */
48export function toAbsolute(origin: string, pathname: string): string {
49 return new URL(pathname, origin).href
⋯ 1 line hidden (lines 51–51)
51 
52/**
53 * The site-wide JSON-LD (schema.org `VideoGame`) for Google rich results. Pure, constant
54 * brand data (no user input), so it's safe to inline as `application/ld+json`. Kept minimal
55 * and accurate — no `offers`, since the game is freemium, not free software. The return type
56 * is the inferred object literal (every key statically known), so a dropped/renamed field is
57 * a compile error, not a silent runtime gap.
58 */
59export function buildJsonLd(origin: string, image: string) {
60 return {
61 '@context': 'https://schema.org',
62 '@type': 'VideoGame',
63 name: SITE_NAME,
64 url: toAbsolute(origin, '/'),
65 image,
66 description: SITE_DESCRIPTION,
67 genre: 'Strategy',
68 gamePlatform: 'Web browser'
69 }
71 
72/**
73 * Serialize data as JSON safe to inline inside a `<script>` tag. `JSON.stringify` does NOT
74 * escape `<`, so a `</script>` in any value would break out of the script context; escaping
75 * every `<` to its Unicode-escape form closes that off regardless of input. Today the only
76 * dynamic value in the JSON-LD is the request origin, which the URL parser already forbids
77 * `<` in — this keeps the inline script safe even if that ever changes.
78 */
79export function inlineJson(data: unknown): string {
80 return JSON.stringify(data).replace(/</g, '\\u003c')

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
app.vue94 lines · Vue
⋯ 39 lines hidden (lines 1–39)
1<template>
2 <!-- Root application wrapper with Nuxt UI integration -->
3 <UApp>
4 <!-- Accessibility announcements for route changes -->
5 <NuxtRouteAnnouncer />
6 <!-- Page content rendered here -->
7 <NuxtPage />
8 </UApp>
9</template>
10 
11<script setup lang="ts">
12/**
13 * Root App component for Everwhen game.
14 *
15 * Provides the app shell + the browser-level "chrome" that matches the warm ledger
16 * aesthetic: a struck-seal favicon and a warm theme-color for the mobile browser bar
17 * (per light/dark). The page <title>/description live in pages/index.
18 *
19 * It also carries the SITE-WIDE social/preview default (issue #118): an og/twitter card
20 * pointed at the brand image (`public/og-default.png`), the polish tags (image alt,
21 * og:locale, apple-touch-icon, canonical, twitter:site/creator), a web-app manifest, and
22 * schema.org JSON-LD — so ANY public page that doesn't set its own card still unfurls with
23 * the brand card. Specific pages (the landing, the per-run share page) override these by
24 * setting the same og/twitter keys, which dedupe last-wins.
25 */
26import {
27 SITE_NAME,
28 SITE_TITLE,
29 SITE_DESCRIPTION,
30 OG_LOCALE,
31 OG_DEFAULT_IMAGE_PATH,
32 OG_IMAGE_WIDTH,
33 OG_IMAGE_HEIGHT,
34 OG_IMAGE_TYPE,
35 OG_IMAGE_ALT,
36 toAbsolute,
37 buildJsonLd,
38 inlineJson
39} from '~/utils/site-meta'
40 
41// Absolute URLs (crawlers drop relative og:image/og:url). useRequestURL gives the real
42// SSR request origin the crawler hit, the window origin on the client.
43const reqUrl = useRequestURL()
44const origin = reqUrl.origin
45const ogImage = toAbsolute(origin, OG_DEFAULT_IMAGE_PATH)
46// Canonical + og:url default to THIS page's clean URL (path only, no query); a specific
47// page may override og:url, and the canonical self-reference is correct for every page.
48const canonical = toAbsolute(origin, reqUrl.pathname)
49 
50// twitter:site / twitter:creator only when a handle is configured (see nuxt.config.ts).
51const twitterHandle = useRuntimeConfig().public.twitterHandle || ''
52 
53useHead({
54 link: [
55 { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
56 { rel: 'alternate icon', href: '/favicon.ico' },
57 // iOS home screen + some link previews want a real PNG (SVG isn't honored there).
58 { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
59 // Android / PWA install + theming.
60 { rel: 'manifest', href: '/site.webmanifest' },
61 { rel: 'canonical', href: canonical }
62 ],
63 meta: [
64 { name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#faf6ee' },
65 { name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#14110d' }
66 ],
67 // schema.org structured data (Google rich results). inlineJson escapes `<` so the
68 // request-derived origin can never break out of the <script> tag.
69 script: [{ type: 'application/ld+json', innerHTML: inlineJson(buildJsonLd(origin, ogImage)) }]
70})
71 
72// The site-wide social card default. SSR-only: crawlers read the server HTML, and keeping
73// it off the client avoids re-writing tags on SPA navigation.
74useServerSeoMeta({
75 ogSiteName: SITE_NAME,
76 ogType: 'website',
77 ogLocale: OG_LOCALE,
78 ogTitle: SITE_TITLE,
79 description: SITE_DESCRIPTION,
80 ogDescription: SITE_DESCRIPTION,
81 ogUrl: canonical,
82 ogImage,
83 ogImageWidth: OG_IMAGE_WIDTH,
84 ogImageHeight: OG_IMAGE_HEIGHT,
85 ogImageType: OG_IMAGE_TYPE,
86 ogImageAlt: OG_IMAGE_ALT,
87 twitterCard: 'summary_large_image',
88 twitterTitle: SITE_TITLE,
89 twitterDescription: SITE_DESCRIPTION,
90 twitterImage: ogImage,
91 twitterImageAlt: OG_IMAGE_ALT,
92 ...(twitterHandle ? { twitterSite: twitterHandle, twitterCreator: twitterHandle } : {})
93})
⋯ 1 line hidden (lines 94–94)
94</script>

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
pages/index.vue203 lines · Vue
⋯ 156 lines hidden (lines 1–156)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Every message<br >rewrites history.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- The reel — light + dark renders stacked; the one matching the theme shows.
34 The two are frame-identical, so toggling theme crossfades AND hands playback
35 over at the same timestamp (see the watch in the script): the reel keeps
36 playing through the swap, as if it re-colored with the page. Reduced-motion
37 users get the static poster (autoplay off). -->
38 <div data-testid="reel" class="reel mt-10 rounded-lg overflow-hidden border rv-line">
39 <video ref="lightVid" class="reel-vid" :class="{ 'is-on': !isDark }"
40 autoplay loop muted playsinline preload="auto"
41 poster="/reel/reel-light-poster.png" aria-label="A demo of Everwhen gameplay">
42 <source src="/reel/reel-light.mp4" type="video/mp4" />
43 </video>
44 <video ref="darkVid" class="reel-vid" :class="{ 'is-on': isDark }"
45 autoplay loop muted playsinline preload="auto"
46 poster="/reel/reel-dark-poster.png" aria-hidden="true">
47 <source src="/reel/reel-dark.mp4" type="video/mp4" />
48 </video>
49 </div>
50 
51 <!-- How it works — three ledger beats -->
52 <div class="mt-12 border-t rv-line">
53 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
54 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
55 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
56 <span class="min-w-0">
57 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
58 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
59 </span>
60 </div>
61 </div>
62 
63 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
64 Figures are played by AI, not real people, and every reply is AI-generated
65 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
66 Intended for adults (18+).
67 </p>
68 </div>
69 </main>
70 
71 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
72 <div class="max-w-2xl mx-auto flex items-center gap-2">
73 <span class="rv-mono uppercase tracking-wide">Everwhen</span>
74 <span class="rv-hair-c" aria-hidden="true">·</span>
75 <span class="rv-serif italic">the past is not fixed</span>
76 </div>
77 </footer>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
84 * then sends the player into the game at /play (the first run is free). Deliberately
85 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
86 * carries across.
87 */
88import { computed, ref, watch } from 'vue'
89import {
90 SITE_TITLE,
91 SITE_DESCRIPTION,
92 OG_DEFAULT_IMAGE_PATH,
93 OG_IMAGE_WIDTH,
94 OG_IMAGE_HEIGHT,
95 OG_IMAGE_TYPE,
96 OG_IMAGE_ALT,
97 toAbsolute
98} from '~/utils/site-meta'
99 
100const steps = [
101 {
102 glyph: '✶',
103 title: 'Pick a cause',
104 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
105 },
106 {
107 glyph: '✎',
108 title: 'Write to history',
109 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
110 },
111 {
112 glyph: '🎲',
113 title: 'Watch it bend',
114 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
115 }
117 
118// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
119// the OS preference), so a choice made here carries into /play and back.
120const colorMode = useColorMode()
121const isDark = computed(() => colorMode.value === 'dark')
122function toggleDark() {
123 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
125 
126// The reel: a light and a dark render of the SAME (frame-identical) animation,
127// stacked and crossfaded by theme. On a theme toggle, hand playback to the
128// now-visible video at the same timestamp so it keeps playing through the swap —
129// it reads as the reel re-coloring with the page rather than restarting. Both
130// videos play in lockstep; this corrects any drift at the moment of the switch.
131const reduced = usePrefersReducedMotion()
132const lightVid = ref<HTMLVideoElement | null>(null)
133const darkVid = ref<HTMLVideoElement | null>(null)
134watch(isDark, (dark) => {
135 if (reduced.value) return
136 const from = dark ? lightVid.value : darkVid.value
137 const to = dark ? darkVid.value : lightVid.value
138 if (!from || !to) return
139 try { to.currentTime = from.currentTime } catch { /* metadata not ready yet */ }
140 const p = to.play()
141 if (p) p.catch(() => { /* autoplay race — ignore */ })
142})
143 
144// Respect reduced-motion: the videos `autoplay` (so they start without waiting on
145// JS), but if the user prefers reduced motion we stop them and rest on the first
146// frame — the title card, which is also the poster. Detected after mount, so this
147// fires once matchMedia resolves.
148watch(reduced, (r) => {
149 if (!r) return
150 for (const v of [lightVid.value, darkVid.value]) {
151 if (!v) continue
152 v.pause()
153 try { v.currentTime = 0 } catch { /* not ready */ }
154 }
155}, { immediate: true })
156 
157// The landing is the most-shared URL, so it gets an explicit card (issue #118) mirroring
158// the share page's shape, pointed at the brand image. Same values as the app.vue site
159// default, restated here so the landing's unfurl is self-contained and legible (the keys
160// dedupe last-wins). Absolute og:image/og:url, or crawlers drop them.
161const landingOrigin = useRequestURL().origin
162const landingOgImage = toAbsolute(landingOrigin, OG_DEFAULT_IMAGE_PATH)
163const landingUrl = toAbsolute(landingOrigin, '/')
164useServerSeoMeta({
165 ogTitle: SITE_TITLE,
166 description: SITE_DESCRIPTION,
167 ogDescription: SITE_DESCRIPTION,
168 ogType: 'website',
169 ogUrl: landingUrl,
170 ogImage: landingOgImage,
171 ogImageWidth: OG_IMAGE_WIDTH,
172 ogImageHeight: OG_IMAGE_HEIGHT,
173 ogImageType: OG_IMAGE_TYPE,
174 ogImageAlt: OG_IMAGE_ALT,
175 twitterCard: 'summary_large_image',
176 twitterTitle: SITE_TITLE,
177 twitterDescription: SITE_DESCRIPTION,
178 twitterImage: landingOgImage,
179 twitterImageAlt: OG_IMAGE_ALT
180})
181// The browser tab title (kept out of useServerSeoMeta so the two don't both write <title>).
182useHead({ title: SITE_TITLE })
⋯ 21 lines hidden (lines 183–203)
183</script>
184 
185<style scoped>
186/* The reel frame holds the two stacked renders at the reel's native 1000:560. The
187 matching theme is faded in; both play, so the crossfade morphs the palette. */
188.reel { position: relative; aspect-ratio: 1000 / 560; background: var(--rv-bg); }
189.reel-vid {
190 position: absolute;
191 inset: 0;
192 width: 100%;
193 height: 100%;
194 object-fit: cover;
195 display: block;
196 opacity: 0;
197 transition: opacity 240ms ease;
199.reel-vid.is-on { opacity: 1; }
200@media (prefers-reduced-motion: reduce) {
201 .reel-vid { transition: none; }
203</style>

The per-run share page sets its own image alt

One sharp edge the site-wide default introduces: the share page points og:image at the per-run card (/api/og/:token), not the brand card. If it inherited the default's alt text, the per-run card would be described as the brand card — a wrong description for screen readers and the platforms that surface alt. So the share page now sets its own image alt, the same way it already overrides title and image.

A per-run alt computed from the run, registered as ogImageAlt + twitterImageAlt.

pages/r/[token].vue · 169 lines
pages/r/[token].vue169 lines · Vue
⋯ 136 lines hidden (lines 1–136)
1<template>
2 <!-- A shared run, public + read-only (issue #88). Reached by an unguessable token, no
3 login. It replays the end screen from the sanitized public projection — the same
4 RunView a saved run uses — adds opt-in attribution, ways to pass it on, and a CTA
5 into a run of your own. Server-rendered so the link unfurls rich in social feeds. -->
6 <div class="min-h-screen rv-bg flex flex-col">
7 <header class="border-b rv-line">
8 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
9 <GameTitle />
10 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
11 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
12 </div>
13 </header>
14 
15 <main class="flex-1 px-5 py-8">
16 <div class="max-w-4xl mx-auto">
17 <div v-if="pending" data-testid="share-loading" class="rv-faint italic text-sm mt-8">Opening the run…</div>
18 
19 <div v-else-if="!run" data-testid="share-not-found" class="mt-8 border-t rv-line pt-6">
20 <h2 class="rv-serif rv-fg text-2xl font-bold">This run isn't available</h2>
21 <p class="rv-muted text-sm mt-2">The link may be wrong, or the run was un-shared by its owner.</p>
22 <NuxtLink to="/play" data-testid="share-not-found-cta" class="rv-btn rv-btn--primary mt-4 inline-flex">
23 Rewrite your own history <span aria-hidden="true"></span>
24 </NuxtLink>
25 </div>
26 
27 <template v-else>
28 <p v-if="attribution" data-testid="share-attribution" class="rv-label">
29 A run shared by {{ attribution }}
30 </p>
31 <RunView :snapshot="run" :remixed-from="remixedFrom" class="mt-3" />
32 
33 <!-- Pass it on -->
34 <div class="mt-9 border-t rv-line pt-6">
35 <span class="rv-label rv-label--rule">Pass it on</span>
36 <ShareLinks :url="pageUrl" :text="shareText" />
37 </div>
38 
39 <!-- The funnel: turn a viewer into a player. The headline act is REPLAY —
40 start your own run on this very objective (issue #89), credited back to
41 this sharer — with a plain "from scratch" path kept as the alternative. -->
42 <div class="mt-7">
43 <p class="rv-muted text-sm mb-3">Think you can do better? Take on this very objective — your own five messages, your own timeline.</p>
44 <button type="button" data-testid="share-play-cta" class="rv-btn rv-btn--primary text-base inline-flex" @click="playObjective">
45 Play this objective <span aria-hidden="true"></span>
46 </button>
47 <p class="mt-3">
48 <button type="button" data-testid="share-scratch-cta" class="rv-tap rv-hover-fg text-xs rv-mono uppercase tracking-wide" @click="playFresh">
49 or rewrite your own history from scratch →
50 </button>
51 </p>
52 </div>
53 </template>
54 </div>
55 </main>
56 
57 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
58 <div class="max-w-4xl mx-auto flex items-center gap-2">
59 <span class="rv-mono uppercase tracking-wide">Everwhen</span>
60 <span class="rv-hair-c" aria-hidden="true">·</span>
61 <span class="rv-serif italic">this timeline is written</span>
62 </div>
63 </footer>
64 </div>
65</template>
66 
67<script setup lang="ts">
68/**
69 * The public run page (/r/:token). Fetches the sanitized public projection from
70 * GET /api/share/:token (which 404s an unknown/revoked token → the "not available"
71 * state) and hands its snapshot to RunView — the very component the owner's saved-run
72 * page uses, so a shared run reads exactly like the real end screen. The SEO/unfurl meta
73 * is set with useServerSeoMeta so it's present in the SSR HTML crawlers read, with the
74 * og:image pointed at the per-run share card.
75 */
76import { computed } from 'vue'
77import { buildShareText, type PublicRun } from '~/utils/run-snapshot'
78import { useGameStore } from '~/stores/game'
79 
80const route = useRoute()
81const token = computed(() => String(route.params.token))
82 
83const { data, pending } = await useFetch<PublicRun>(() => `/api/share/${token.value}`)
84const run = computed(() => data.value?.run ?? null)
85const attribution = computed(() => data.value?.attribution ?? null)
86// When THIS shared run is itself a replay, its own "remixed from" credit (issue #89).
87const remixedFrom = computed(() => data.value?.remixedFrom ?? null)
88 
89// Replay (issue #89): seed a fresh run on this run's captured objective, credited back
90// to this sharer, then drop into the briefing. No generation — the objective is reused
91// verbatim; the visitor still commits (and is charged) by pressing Begin.
92const gameStore = useGameStore()
93async function playObjective() {
94 if (!run.value) return
95 gameStore.preseedReplay(run.value.objective, token.value, attribution.value)
96 await navigateTo('/play')
98// The alternative: start from scratch — explicitly an original run, no lineage.
99async function playFresh() {
100 gameStore.clearReplayState()
101 await navigateTo('/play')
103 
104// Absolute URLs for the canonical link + the share card. useRequestURL gives the real
105// origin during SSR (what the crawler fetched) and the window origin on the client.
106const reqUrl = useRequestURL()
107const pageUrl = computed(() => `${reqUrl.origin}/r/${token.value}`)
108const cardUrl = computed(() => `${reqUrl.origin}/api/og/${token.value}`)
109 
110const isVictory = computed(() => run.value?.status === 'victory')
111const verdict = computed(() => (isVictory.value ? 'History Rewritten' : 'The Timeline Held'))
112 
113const shareText = computed(() =>
114 run.value
115 ? buildShareText(run.value.objective.title, run.value.status, run.value.objectiveProgress)
116 : 'Everwhen — every message rewrites history.'
118 
119const colorMode = useColorMode()
120const isDark = computed(() => colorMode.value === 'dark')
121function toggleDark() {
122 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
124 
125const metaTitle = computed(() => {
126 const r = run.value
127 if (!r) return 'A shared run — Everwhen'
128 return `${r.objective.title}: ${r.objectiveProgress}% rewritten — Everwhen`
129})
130const metaDescription = computed(() => {
131 const r = run.value
132 if (!r) return 'A run of Everwhen — every message rewrites history. Text anyone in history; a D20 and an AI bend the timeline.'
133 const epilogue = r.chronicle?.title
134 return epilogue ? `${verdict.value} · "${epilogue}"` : `${verdict.value} · ${r.objective.title} (${r.objectiveProgress}% rewritten).`
135})
136// The og:image here is the PER-RUN card (the verdict + objective + % card), not the brand
137// card — so it needs its own alt, or it would inherit the site-default brand-card alt and
138// mis-describe itself (issue #118).
139const metaImageAlt = computed(() => {
140 const r = run.value
141 if (!r) return 'An Everwhen run share card.'
142 return `Everwhen run card: ${verdict.value}${r.objective.title}, ${r.objectiveProgress}% rewritten.`
143})
144 
145// Server-rendered unfurl: title/description + an OG/Twitter card pointed at the per-run
146// share image, so a pasted link looks good in X / Discord / iMessage.
147useServerSeoMeta({
148 ogSiteName: 'Everwhen',
149 ogTitle: metaTitle,
150 description: metaDescription,
151 ogDescription: metaDescription,
152 ogType: 'article',
153 ogUrl: pageUrl,
154 ogImage: cardUrl,
155 ogImageWidth: 1200,
156 ogImageHeight: 630,
157 ogImageType: 'image/png',
158 ogImageAlt: metaImageAlt,
159 twitterCard: 'summary_large_image',
160 twitterTitle: metaTitle,
161 twitterDescription: metaDescription,
162 twitterImage: cardUrl,
163 twitterImageAlt: metaImageAlt
164})
⋯ 5 lines hidden (lines 165–169)
165 
166// The browser tab title (server + client nav); kept out of useServerSeoMeta so the two
167// don't both write <title>.
168useHead({ title: metaTitle })
169</script>

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
nuxt.config.ts95 lines · TypeScript
⋯ 13 lines hidden (lines 1–13)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Declare the document language app-wide (issue #118). Set here rather than in app.vue so
13 // it also reaches routes that bypass the app component — Nuxt's built-in error/404 page —
14 // keeping <html lang="en"> consistent everywhere (accessibility; pairs with og:locale).
15 app: {
16 head: {
17 htmlAttrs: { lang: 'en' }
18 }
19 },
⋯ 58 lines hidden (lines 20–77)
20 
21 // Register required modules
22 modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/supabase'],
23 
24 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
25 // user so the balance keys on a real user id from the first second; signing in
26 // with a magic link upgrades that same user in place (balance carries over).
27 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
28 // free-trial hook); buying is what requires an account. It also means the module
29 // registers no /confirm callback route, so the magic-link return is finalized in
30 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
31 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
32 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
33 // back to the Site URL and the link never reaches the handler. The client key is
34 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
35 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
36 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
37 supabase: {
38 url: process.env.SUPABASE_URL || 'https://placeholder.supabase.co',
39 key: process.env.SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_placeholder',
40 redirect: false,
41 // We don't use generated DB types (the balance store talks to Supabase with the
42 // service client directly); disable the lookup so the module doesn't warn.
43 types: false
44 },
45 
46 // Global CSS imports
47 css: ['~/assets/css/main.css'],
48 
49 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
50 // class on <html> is exactly `dark` / `light` — what the .dark token block and
51 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
52 // the module's inline no-flash script + cookie persistence replace the old manual
53 // classList toggle (which didn't persist and could FOUC).
54 colorMode: {
55 classSuffix: '',
56 preference: 'system',
57 fallback: 'light'
58 },
59 
60 // Runtime configuration
61 runtimeConfig: {
62 // Private keys (only available on server-side)
63 openaiApiKey: process.env.OPENAI_API_KEY,
64 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
65 // Supabase — the run store (server-side only). Absent → in-memory dev store.
66 supabaseUrl: process.env.SUPABASE_URL,
67 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
68 // Stripe — run packs (server-side only). Absent → checkout returns 503.
69 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
70 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
71 // Canonical site origin for Stripe redirect URLs (never the client Host header).
72 siteUrl: process.env.REVISIONIST_BASE_URL,
73 // Public keys (exposed to client-side)
74 public: {
75 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
76 // this flag additionally opts a DEPLOYED staging preview in. Never set in
77 // production. The server gates the fixture lane on the same env var, so the
78 // client panel and the server seam agree on one source of truth.
79 devMode: process.env.REVISIONIST_DEV_MODE === 'true',
80 // The brand's X/Twitter @handle for twitter:site / twitter:creator attribution
81 // (issue #118). Unset → those two tags are simply omitted (a guessed handle would
82 // mis-attribute the card to a stranger). Set REVISIONIST_TWITTER_HANDLE (e.g.
83 // "@everwhen") in production to activate them.
84 twitterHandle: process.env.REVISIONIST_TWITTER_HANDLE || ''
85 }
⋯ 10 lines hidden (lines 86–95)
86 },
87 
88 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
89 // out of the app's type program so `nuxt typecheck` never compiles the loops.
90 typescript: {
91 tsConfig: {
92 exclude: ['../agents']
93 }
94 }
95})

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
scripts/gen-brand-icons.mjs47 lines · JavaScript
⋯ 36 lines hidden (lines 1–36)
1/**
2 * Generates the square brand app icons from one source SVG (issue #118):
3 * public/apple-touch-icon.png (180x180 — iOS home screen + some link previews)
4 * public/icon-192.png (192x192 — web-app manifest / Android)
5 * public/icon-512.png (512x512 — manifest splash / maskable)
6 *
7 * The geometry is the brand seal + astrolabe from public/favicon.svg, re-laid on a
8 * full-bleed cream tile (no rounded corners — the platform applies its own mask) with the
9 * seal sized to sit inside the maskable safe zone. Rasterized with @resvg/resvg-js (already
10 * a dependency). The PNGs are committed; rerun this only when the brand mark changes:
11 *
12 * node scripts/gen-brand-icons.mjs
13 */
14import { Resvg } from '@resvg/resvg-js'
15import { writeFileSync } from 'node:fs'
16import { fileURLToPath } from 'node:url'
17import { dirname, join } from 'node:path'
18 
19const BG = '#faf6ee' // cream tile — matches the light theme-color + manifest background
20const SEAL = '#b1532e' // deep terracotta — reads strong on cream
21const CUT = '#faf6ee' // the astrolabe is cut out of the seal in the tile color
22 
23// The seal + astrolabe from favicon.svg, centered and scaled to leave maskable padding
24// (the scallop tips stay within the inner ~80% safe circle of the 512 canvas).
25const MARK = `<g transform="translate(256 256) scale(4)">
26 <g fill="${SEAL}"><circle r="41"/><circle cx="41.00" cy="0.00" r="7.90"/><circle cx="37.46" cy="16.68" r="7.90"/><circle cx="27.43" cy="30.47" r="7.90"/><circle cx="12.67" cy="38.99" r="7.90"/><circle cx="-4.29" cy="40.78" r="7.90"/><circle cx="-20.50" cy="35.51" r="7.90"/><circle cx="-33.17" cy="24.10" r="7.90"/><circle cx="-40.10" cy="8.52" r="7.90"/><circle cx="-40.10" cy="-8.52" r="7.90"/><circle cx="-33.17" cy="-24.10" r="7.90"/><circle cx="-20.50" cy="-35.51" r="7.90"/><circle cx="-4.29" cy="-40.78" r="7.90"/><circle cx="12.67" cy="-38.99" r="7.90"/><circle cx="27.43" cy="-30.47" r="7.90"/><circle cx="37.46" cy="-16.68" r="7.90"/></g>
27 <g fill="none" stroke="${CUT}" stroke-linecap="round"><circle r="27" stroke-width="4"/><line x1="-27" y1="0" x2="27" y2="0" stroke-width="3"/><line x1="-23.4" y1="-13.5" x2="23.4" y2="13.5" stroke-width="4"/></g>
28 <g fill="${CUT}"><path d="M0,-8.4 L2.9,-2.9 L8.4,0 L2.9,2.9 L0,8.4 L-2.9,2.9 L-8.4,0 L-2.9,-2.9 Z" transform="translate(0,-27)"/><circle r="4.8"/></g>
29</g>`
30 
31const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
32 <rect width="512" height="512" fill="${BG}"/>
33 ${MARK}
34</svg>`
35 
36const root = join(dirname(fileURLToPath(import.meta.url)), '..')
37const targets = [
38 ['apple-touch-icon.png', 180],
39 ['icon-192.png', 192],
40 ['icon-512.png', 512]
42 
43for (const [name, size] of targets) {
44 const png = new Resvg(SVG, { fitTo: { mode: 'width', value: size } }).render().asPng()
45 writeFileSync(join(root, 'public', name), png)
46 console.log(`wrote public/${name} (${size}x${size}, ${png.length} bytes)`)
⋯ 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.

tests/unit/utils/site-meta.spec.ts · 86 lines
tests/unit/utils/site-meta.spec.ts86 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1import { describe, it, expect } from 'vitest'
2import {
3 SITE_NAME,
4 SITE_TITLE,
5 SITE_DESCRIPTION,
6 OG_IMAGE_ALT,
7 OG_DEFAULT_IMAGE_PATH,
8 toAbsolute,
9 buildJsonLd,
10 inlineJson
11} from '../../../utils/site-meta'
12 
13/**
14 * The shared brand/social-card source of truth (issue #118). The one thing that genuinely
15 * breaks an unfurl is a RELATIVE og:image / og:url — crawlers drop those silently — so the
16 * load-bearing guard here is that `toAbsolute` always yields an absolute URL. The rest pins
17 * the brand strings + JSON-LD shape so a future edit can't quietly empty them.
18 */
19describe('site-meta (issue #118)', () => {
20 const ORIGIN = 'https://everwhen.example'
21 
22 describe('toAbsolute — the absolute-URL guard', () => {
23 it('absolutizes the brand image path', () => {
24 expect(toAbsolute(ORIGIN, OG_DEFAULT_IMAGE_PATH)).toBe('https://everwhen.example/og-default.png')
25 })
26 
27 it('absolutizes the site root', () => {
28 expect(toAbsolute(ORIGIN, '/')).toBe('https://everwhen.example/')
29 })
30 
31 it('never returns a relative URL', () => {
32 for (const path of ['/og-default.png', '/', '/r/abc', '/play']) {
33 expect(toAbsolute(ORIGIN, path)).toMatch(/^https?:\/\//)
34 }
35 })
36 
37 it('tolerates an origin with a trailing slash without doubling it', () => {
38 expect(toAbsolute('https://everwhen.example/', '/og-default.png')).toBe(
39 'https://everwhen.example/og-default.png'
40 )
41 })
42 })
⋯ 27 lines hidden (lines 43–69)
43 
44 describe('brand constants', () => {
45 it('exposes non-empty brand strings', () => {
46 for (const s of [SITE_NAME, SITE_TITLE, SITE_DESCRIPTION, OG_IMAGE_ALT]) {
47 expect(s.trim().length).toBeGreaterThan(0)
48 }
49 })
50 
51 it('describes the actual card in the alt text', () => {
52 expect(OG_IMAGE_ALT).toContain('Everwhen')
53 })
54 })
55 
56 describe('buildJsonLd', () => {
57 it('emits valid schema.org VideoGame with an absolute image + url', () => {
58 const image = toAbsolute(ORIGIN, OG_DEFAULT_IMAGE_PATH)
59 const ld = buildJsonLd(ORIGIN, image)
60 expect(ld['@context']).toBe('https://schema.org')
61 expect(ld['@type']).toBe('VideoGame')
62 expect(ld.name).toBe(SITE_NAME)
63 expect(ld.url).toBe('https://everwhen.example/')
64 expect(ld.image).toBe(image)
65 // Serializes cleanly (it's inlined as application/ld+json).
66 expect(() => JSON.stringify(ld)).not.toThrow()
67 })
68 })
69 
70 describe('inlineJson — <script> breakout guard', () => {
71 it('escapes < so a value cannot close the script tag', () => {
72 const out = inlineJson({ x: '</script><img src=x onerror=alert(1)>' })
73 expect(out).not.toContain('</script>')
74 expect(out).not.toContain('<img')
75 expect(out).toContain('\\u003c')
76 })
77 
78 it('round-trips back to the original data (escaping is transparent to JSON.parse)', () => {
79 const data = { a: 1, b: 'no angle brackets here' }
80 expect(JSON.parse(inlineJson(data))).toEqual(data)
81 // A value WITH a '<' still parses back identically.
82 const tricky = { html: 'a < b </script>' }
83 expect(JSON.parse(inlineJson(tricky))).toEqual(tricky)
⋯ 3 lines hidden (lines 84–86)
84 })
85 })
86})