What changed, and why

The "Your runs" pages let a player revisit finished runs, but they made it hard to get back into the game. On the list (/runs), the "Play your first run" button showed only when you had no runs; once you had saved runs, the single route to /play was a faint footer link, easy to miss. On a run's detail page (/runs/:id), the footer was plain text with no link, so that page had no path to the game at all.

This PR adds one obvious, persistent action — "Start a new run"/play — to both pages, and turns the detail page's dead footer text into a real link. Three files change: the two pages, and an end-to-end test. It's the mirror of #108 (which added a "Your runs" link to the win/lose screen), so navigation between play and runs is now obvious in both directions.

The list: an action that's always there

The heading area becomes a two-column row: the title block on the left, the "Start a new run" button on the right. The button is a NuxtLink to /play styled with the same rv-btn rv-btn--primary class the empty-state CTA uses, so it reads as the page's primary action.

The key detail is where it sits. The button lives in the heading block, above the v-if="pending" / v-else-if / v-else chain that swaps between the loading, error, empty, and list states (the first of those starts at line 35). Because it's a sibling of that chain rather than inside any branch, it renders in every state — including the populated list, which is exactly the case that used to have only the footer link.

The CTA sits in the heading row, before the state branches — so it shows regardless of run count.

pages/runs/index.vue · 108 lines
pages/runs/index.vue108 lines · Vue
⋯ 15 lines hidden (lines 1–15)
1<template>
2 <!-- Your runs — the list of saved, finished runs for this account (or this
3 anonymous session). Each row opens a read-only replay at /runs/:id. Reuses the
4 landing's page chrome so it reads as part of the same warm ledger. -->
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 <!-- A persistent "start a new run" page action sits beside the heading, shown in
17 every state (not just the empty one): once you have saved runs, the only other
18 way back to the game was a faint footer link. Mirror of #108 (the "Your runs"
19 link on the end screen); reuses the empty-state CTA's primary button + ▷ glyph. -->
20 <div class="flex items-start justify-between gap-4 flex-wrap">
21 <div>
22 <p class="rv-label">A history you have written</p>
23 <h2 class="rv-serif rv-fg text-3xl sm:text-4xl font-bold mt-2 leading-tight">Your runs</h2>
24 </div>
25 <NuxtLink to="/play" data-testid="runs-start-cta"
26 class="rv-btn rv-btn--primary shrink-0 inline-flex items-center gap-1.5">
27 Start a new run <span aria-hidden="true"></span>
28 </NuxtLink>
29 </div>
30 <p class="rv-muted text-sm mt-3 leading-relaxed max-w-xl">
31 Every run you finish is kept here — the rewritten history, the epilogue, the
32 verdict. Open one to revisit it.
33 </p>
34 
35 <div v-if="pending" data-testid="runs-loading" class="rv-faint italic text-sm mt-8">Gathering your runs…</div>
⋯ 73 lines hidden (lines 36–108)
36 
37 <div v-else-if="error" data-testid="runs-error" class="rv-bad text-sm mt-8">
38 Could not load your runs. Please try again.
39 </div>
40 
41 <div v-else-if="runs.length === 0" data-testid="runs-empty" class="mt-8 border-t rv-line pt-6">
42 <p class="rv-muted text-sm">No saved runs yet.</p>
43 <NuxtLink to="/play" data-testid="runs-empty-cta" class="rv-btn rv-btn--primary mt-4 inline-flex">
44 Play your first run <span aria-hidden="true"></span>
45 </NuxtLink>
46 </div>
47 
48 <ul v-else data-testid="runs-list" class="mt-8 border-t rv-line">
49 <li v-for="run in runs" :key="run.runId" class="border-b rv-line">
50 <NuxtLink :to="`/runs/${run.runId}`" :data-testid="`run-link-${run.runId}`"
51 class="flex items-center gap-4 py-4 rv-tap group">
52 <span class="text-xl leading-none shrink-0" :class="run.status === 'victory' ? 'rv-ok' : 'rv-muted'"
53 aria-hidden="true">{{ run.status === 'victory' ? '✦' : '⊘' }}</span>
54 <span class="min-w-0 flex-1">
55 <span class="rv-fg font-semibold block truncate group-hover:underline">{{ run.title || 'Historical Mission' }}</span>
56 <span class="rv-faint text-[11px] rv-mono">{{ verdictWord(run.status) }} · {{ formatDate(run.createdAt) }}</span>
57 </span>
58 <span class="rv-mono text-sm font-bold shrink-0" :class="run.status === 'victory' ? 'rv-ok' : 'rv-bad'">
59 {{ run.progress }}%
60 </span>
61 </NuxtLink>
62 </li>
63 </ul>
64 </div>
65 </main>
66 
67 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
68 <div class="max-w-2xl mx-auto flex items-center gap-2">
69 <NuxtLink to="/play" class="rv-tap rv-hover-fg rv-mono uppercase tracking-wide">← Back to play</NuxtLink>
70 <span class="rv-hair-c" aria-hidden="true">·</span>
71 <span class="rv-serif italic">the past is not fixed</span>
72 </div>
73 </footer>
74 </div>
75</template>
76 
77<script setup lang="ts">
78/**
79 * Your runs — lists this subject's saved runs (newest first) from GET /api/runs.
80 * Read-only; each row links to /runs/:id. The subject is resolved server-side, so an
81 * anonymous player sees their own session's runs and a signed-in player sees theirs.
82 */
83import { computed } from 'vue'
84import type { RunSummary } from '~/utils/run-snapshot'
85 
86const { data, pending, error } = await useFetch<{ runs: RunSummary[] }>('/api/runs')
87const runs = computed(() => data.value?.runs ?? [])
88 
89const colorMode = useColorMode()
90const isDark = computed(() => colorMode.value === 'dark')
91function toggleDark() {
92 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
94 
95const verdictWord = (status: RunSummary['status']) => (status === 'victory' ? 'History rewritten' : 'The timeline held')
96 
97function formatDate(iso: string): string {
98 const d = new Date(iso)
99 return Number.isNaN(d.getTime())
100 ? ''
101 : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
103 
104useHead({
105 title: 'Your runs — Revisionist',
106 meta: [{ name: 'description', content: 'Revisit the runs you have finished — the rewritten history, the epilogue, the verdict.' }]
107})
108</script>

The detail page: no longer a dead-end

The replay page gets the same treatment. The ← Your runs back-link and a new "Start a new run" button share a row (lines 19–25), placed above the loading / not-found / replay branches — so the button is present in all three. That alone removes the dead-end.

The footer change is the second half. The old footer was a static Revisionist wordmark next to a tagline; it's now a real ← Back to play link (line 47), matching the list page's footer. The "this timeline is written" tagline stays.

Top: the CTA beside the back-link, above the state branches. Bottom: the footer is a link now, not dead text.

pages/runs/[id].vue · 86 lines
pages/runs/[id].vue86 lines · Vue
⋯ 14 lines hidden (lines 1–14)
1<template>
2 <!-- One saved run, read-only — replays the end screen from the stored snapshot.
3 Reachable only for runs this subject owns (the server 404s otherwise). -->
4 <div class="min-h-screen rv-bg flex flex-col">
5 <header class="border-b rv-line">
6 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
7 <GameTitle />
8 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
9 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
10 </div>
11 </header>
12 
13 <main class="flex-1 px-5 py-8">
14 <div class="max-w-4xl mx-auto">
15 <!-- A saved-run replay used to be a dead-end: "← Your runs" only went back to the
16 list, and the footer was static text — no path to the game. A persistent "start
17 a new run" action now sits beside the back-link in every state (issue #112,
18 mirror of #108's "Your runs" link on the end screen). -->
19 <div class="flex items-center justify-between gap-4 flex-wrap">
20 <NuxtLink to="/runs" class="rv-tap rv-hover-fg text-[11px] rv-mono uppercase tracking-wide">← Your runs</NuxtLink>
21 <NuxtLink to="/play" data-testid="run-start-cta"
22 class="rv-btn rv-btn--primary shrink-0 inline-flex items-center gap-1.5">
23 Start a new run <span aria-hidden="true"></span>
24 </NuxtLink>
25 </div>
⋯ 19 lines hidden (lines 26–44)
26 
27 <div v-if="pending" data-testid="run-loading" class="rv-faint italic text-sm mt-8">Opening the run…</div>
28 
29 <div v-else-if="!run" data-testid="run-not-found" class="mt-8 border-t rv-line pt-6">
30 <h2 class="rv-serif rv-fg text-2xl font-bold">Run not found</h2>
31 <p class="rv-muted text-sm mt-2">This run doesn't exist, or it isn't yours to view.</p>
32 <NuxtLink to="/runs" data-testid="run-not-found-back" class="rv-btn rv-btn--primary mt-4 inline-flex">
33 Back to your runs
34 </NuxtLink>
35 </div>
36 
37 <template v-else>
38 <RunView :snapshot="run" class="mt-5" />
39 <!-- Make this saved run public, or revoke it (issue #88). -->
40 <ShareControls :run-id="id" :share-text="shareText" class="mt-8" />
41 </template>
42 </div>
43 </main>
44 
45 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
46 <div class="max-w-4xl mx-auto flex items-center gap-2">
47 <NuxtLink to="/play" class="rv-tap rv-hover-fg rv-mono uppercase tracking-wide">← Back to play</NuxtLink>
48 <span class="rv-hair-c" aria-hidden="true">·</span>
49 <span class="rv-serif italic">this timeline is written</span>
50 </div>
⋯ 36 lines hidden (lines 51–86)
51 </footer>
52 </div>
53</template>
54 
55<script setup lang="ts">
56/**
57 * A saved run, read-only — fetches GET /api/runs/:id and hands the snapshot to
58 * RunView. Ownership is enforced server-side (a non-owned or unknown id 404s), which
59 * surfaces here as the "run not found" state rather than a leak.
60 */
61import { computed } from 'vue'
62import { buildShareText, type RunSnapshot } from '~/utils/run-snapshot'
63 
64const route = useRoute()
65const id = computed(() => String(route.params.id))
66 
67const { data, pending } = await useFetch<{ run: RunSnapshot }>(() => `/api/runs/${id.value}`)
68const run = computed(() => data.value?.run ?? null)
69 
70// The brag line a share prefills (objective + verdict + % rewritten).
71const shareText = computed(() =>
72 run.value
73 ? buildShareText(run.value.objective.title, run.value.status, run.value.objectiveProgress)
74 : 'Revisionist — rewrite history, one message at a time.'
76 
77const colorMode = useColorMode()
78const isDark = computed(() => colorMode.value === 'dark')
79function toggleDark() {
80 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
82 
83useHead({
84 title: computed(() => (run.value ? `${run.value.objective.title || 'A run'} — Revisionist` : 'Run — Revisionist'))
85})
86</script>

Verification: a real saved run

The list is server-rendered (useFetch('/api/runs') runs during SSR), so a browser-side route mock can't seed it — the populated-list test plays a run to completion instead. It wins a run, waits for the POST /api/run-save that persists the snapshot, then loads /runs and asserts both the run list and the persistent CTA are present. That's the discriminating check: it fails if anyone ever gates the button behind the empty state again.

The spec also covers the empty list and the detail not-found state. All three pass against a production build; the repo's CI gates (nuxt typecheck, vitest run) stay green.

The populated-list test: play → save → reload /runs → the CTA is still there.

tests/e2e/features/runs.spec.ts · 68 lines
tests/e2e/features/runs.spec.ts68 lines · TypeScript
⋯ 24 lines hidden (lines 1–24)
1import { test, expect } from '@nuxt/test-utils/playwright'
2import { mockSendMessage, mockChronicle, startCuratedRun, selectFigure, fillAndSend } from '../support/game'
3 
4/**
5 * Your runs → play navigation (issue #112, the mirror of #108). From the saved-runs
6 * pages there must be an obvious, persistent way back into the game. The list (empty
7 * AND populated) and a run detail page (even when the run is not found) each surface a
8 * "Start a new run" → /play affordance, so neither page is a dead-end.
9 */
10test.describe('Your runs → play navigation', () => {
11 test('the runs list surfaces "Start a new run" → /play with no saved runs', async ({ page, goto }) => {
12 await goto('/runs', { waitUntil: 'hydration' })
13 
14 // The empty state keeps its first-run hero CTA...
15 await expect(page.locator('[data-testid="runs-empty"]')).toBeVisible()
16 // ...and the persistent header action is shown alongside it.
17 const cta = page.locator('[data-testid="runs-start-cta"]')
18 await expect(cta).toBeVisible()
19 await expect(cta).toHaveAttribute('href', '/play')
20 
21 await cta.click()
22 await expect(page).toHaveURL(/\/play$/)
23 })
24 
25 test('the runs list still surfaces "Start a new run" once a run is saved', async ({ page, goto }) => {
26 // The bug this fixes: with saved runs, the only route back to play was a faint
27 // footer link. Play a run to completion so it persists (the list is server-rendered,
28 // so a route mock can't seed it), then assert the populated list still shows the
29 // persistent CTA — it is not gated to the empty state.
30 await goto('/play', { waitUntil: 'hydration' })
31 await mockSendMessage(page, { diceRoll: 20, diceOutcome: 'Critical Success', progressChange: 100, valence: 'positive' })
32 await startCuratedRun(page)
33 await mockChronicle(page, { title: 'The World Remade', paragraphs: ['One message changed everything.'] })
34 await selectFigure(page, 'Cleopatra')
35 
36 // The end-of-run snapshot save (POST /api/run-save) is what makes the run show at /runs.
37 const saved = page.waitForResponse((r) => r.url().includes('/api/run-save') && r.request().method() === 'POST')
38 await fillAndSend(page, 'Change everything, now.')
39 await expect(page.locator('[data-testid="end-game-screen"]')).toBeVisible()
40 await saved
41 
42 await goto('/runs', { waitUntil: 'hydration' })
43 await expect(page.locator('[data-testid="runs-list"]')).toBeVisible()
44 const cta = page.locator('[data-testid="runs-start-cta"]')
45 await expect(cta).toBeVisible()
46 await expect(cta).toHaveAttribute('href', '/play')
47 })
⋯ 21 lines hidden (lines 48–68)
48 
49 test('a run detail page is not a dead-end — "Start a new run" → /play', async ({ page, goto }) => {
50 // Any id this session does not own renders the not-found state; the affordance
51 // sits above the per-state content, so even here there is a path to the game.
52 await page.route('**/api/runs/does-not-exist', async (route) => {
53 await route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ message: 'Not found' }) })
54 })
55 await goto('/runs/does-not-exist', { waitUntil: 'hydration' })
56 
57 await expect(page.locator('[data-testid="run-not-found"]')).toBeVisible()
58 const cta = page.locator('[data-testid="run-start-cta"]')
59 await expect(cta).toBeVisible()
60 await expect(cta).toHaveAttribute('href', '/play')
61 
62 // The footer is a real link now, not the old dead static text.
63 await expect(page.getByRole('link', { name: /back to play/i })).toHaveAttribute('href', '/play')
64 
65 await cta.click()
66 await expect(page).toHaveURL(/\/play$/)
67 })
68})