What changed, and why

On the win/lose debrief, the Chronicle epilogue — the prose telling of your run — rendered at only about two-thirds the column width, while every section around it (the dispatches, the stats, the timeline) filled the full column. The prose read as an oddly narrow block next to its siblings.

The cause was a single Tailwind utility: the epilogue <article> carried max-w-[62ch]. That is a deliberate readability line-length, but the rest of the debrief sits in the page's max-w-4xl container with no inner cap, so 62ch read as ~2/3 width beside everything else. The fix is one move: drop the cap so the epilogue fills the column like its neighbours. It lives in two mirrored places — the live end screen and the read-only retrospective — so both change together.

Before
Before: The epilogue wraps at ~62ch, leaving a wide empty gutter on the right while the rule line above and “the words you sent” below run the full column.
The epilogue wraps at ~62ch, leaving a wide empty gutter on the right while the rule line above and “the words you sent” below run the full column.
After
After: The cap removed, the epilogue spans the full column — the same measure as the dispatches, stats, and timeline.
The cap removed, the epilogue spans the full column — the same measure as the dispatches, stats, and timeline.

The cap, removed — EndGameScreen

This is the live win/lose takeover. The epilogue is rendered inline here (not via the in-game Chronicle component), so the <article> on line 41 was the only thing narrowing the prose. With the cap gone, the article is a plain block element that fills its parent — and its parent is the max-w-4xl page container on line 5, the exact measure the dispatches, stats, and timeline already use.

Line 41 is the epilogue article (now uncapped); line 5 is the max-w-4xl column it inherits. The paragraphs on line 43 were never capped, so nothing else holds the width back.

components/EndGameScreen.vue · 235 lines
components/EndGameScreen.vue235 lines · Vue
⋯ 4 lines hidden (lines 1–4)
1<template>
2 <div v-if="gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'" ref="dialogRef" data-testid="end-game-screen"
3 role="dialog" aria-modal="true" :aria-labelledby="headingId" @keydown="onKeydown"
4 class="end-screen fixed inset-0 z-50 overflow-y-auto" :class="isVictory ? 'end--win' : 'end--loss'">
5 <div class="max-w-4xl mx-auto px-5 py-8">
⋯ 29 lines hidden (lines 6–34)
6 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
7 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
8 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
9 <div class="text-center border-b rv-line pb-6">
10 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
11 <h2 v-if="isVictory" :id="headingId" ref="headingRef" tabindex="-1" data-testid="victory-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3 outline-none">History Rewritten</h2>
12 <h2 v-else :id="headingId" ref="headingRef" tabindex="-1" data-testid="defeat-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3 outline-none">The Timeline Held</h2>
13 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
14 <span class="rv-muted inline-flex items-center gap-1.5">
15 <span aria-hidden="true">{{ gameStore.currentObjective?.icon }}</span>
16 <strong data-testid="objective-title" class="font-semibold">{{ gameStore.currentObjective?.title || 'Historical Mission' }}</strong>
17 </span>
18 <span class="rv-hair-c" aria-hidden="true">·</span>
19 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
20 <span data-testid="final-progress">{{ gameStore.objectiveProgress }}%</span> rewritten
21 </span>
22 </p>
23 
24 <p v-if="!isVictory" data-testid="defeat-explanation" class="rv-muted text-sm mt-3 max-w-prose mx-auto">
25 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
26 </p>
27 
28 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 rv-mono text-sm">
29 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
30 <span class="rv-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
31 <span v-if="victoryEfficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
32 </div>
33 </div>
34 
35 <!-- The Chronicle's final telling — the epilogue -->
36 <div v-if="chronicle || chronicleLoading" data-testid="end-chronicle" class="mt-6">
37 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
38 <div v-if="!chronicle && chronicleLoading" data-testid="end-chronicle-loading" class="rv-faint italic text-sm">
39 The chronicler sets down the final account…
40 </div>
41 <article v-else-if="chronicle" data-testid="end-chronicle-body">
42 <h4 data-testid="end-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
43 <p v-for="(para, i) in chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
44 </article>
45 </div>
⋯ 190 lines hidden (lines 46–235)
46 
47 <!-- The words you sent — the keepsake: your actual messages into the past -->
48 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
49 <span class="rv-label rv-label--rule">The words you sent</span>
50 <ul class="space-y-3.5">
51 <li v-for="(d, i) in dispatches" :key="i">
52 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
53 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
54 </li>
55 </ul>
56 </div>
57 
58 <!-- Stats — derived from the real resolved turns -->
59 <div data-testid="game-summary" class="mt-7">
60 <span class="rv-label rv-label--rule">How it played out</span>
61 <div class="flex flex-wrap gap-10">
62 <div data-testid="message-efficiency">
63 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ messagesUsed }}</div>
64 <div class="rv-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
65 </div>
66 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
67 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
68 <div class="rv-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
69 </div>
70 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
71 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
72 <div class="rv-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
73 </div>
74 </div>
75 </div>
76 
77 <!-- The history you wrote — the Spine itself, exactly as it filled in during
78 the run. Read-only here: no live "▷ now" present marker, no "the timeline
79 trembles…" empty state. Same component the player scrolled and tapped on
80 the board, so the verdict's history reads as richly as it did in play —
81 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
82 <div v-if="timeline.length > 0" class="mt-6">
83 <TimelineLedger readonly />
84 </div>
85 
86 <div class="mt-9 border-t rv-line pt-6 flex items-center gap-3 flex-wrap">
87 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="rv-btn rv-btn--primary" @click="playAgain">
88 Try a new timeline <span aria-hidden="true"></span>
89 </button>
90 <span class="rv-faint text-xs inline-flex items-center gap-1.5">
91 <span class="rv-dot" :class="isVictory ? 'rv-dot--ok' : 'rv-dot--bad'" aria-hidden="true" /> this timeline is written.
92 </span>
93 </div>
94 </div>
95 </div>
96</template>
97 
98<script setup lang="ts">
99/**
100 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
101 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
102 * wrote. All stats derive from the real resolved turns.
103 */
104import { ref, computed, watch, nextTick } from 'vue'
105import { useGameStore } from '~/stores/game'
106 
107const gameStore = useGameStore()
108 
109const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
110 
111// Modal focus management: it's a true takeover (aria-modal), so when it opens we
112// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
113// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
114// the heading — not the bottom Play Again button — also means the screen opens at
115// the top: the verdict and the Chronicle epilogue are the first things seen, not
116// scrolled past below the fold.
117const dialogRef = ref<HTMLElement | null>(null)
118const headingRef = ref<HTMLElement | null>(null)
119const playAgainRef = ref<HTMLElement | null>(null)
120let prevFocus: HTMLElement | null = null
121 
122watch(() => gameStore.gameStatus, async (s) => {
123 if (typeof document === 'undefined') return
124 if (s === 'victory' || s === 'defeat') {
125 prevFocus = document.activeElement as HTMLElement | null
126 await nextTick()
127 if (dialogRef.value) dialogRef.value.scrollTop = 0
128 headingRef.value?.focus()
129 } else if (prevFocus) {
130 prevFocus.focus?.()
131 prevFocus = null
132 }
133})
134 
135function onKeydown(e: KeyboardEvent) {
136 if (e.key !== 'Tab' || !dialogRef.value) return
137 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
138 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
139 )
140 if (!focusables.length) return
141 const first = focusables[0]
142 const last = focusables[focusables.length - 1]
143 const active = document.activeElement
144 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
145 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
147 
148const isVictory = computed(() => gameStore.gameStatus === 'victory')
149const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
150 
151// The verdict never deflates: spending every message is reframed as "Hard-won," not
152// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
153const victoryPhrase = computed(() => {
154 const rating = victoryEfficiency.value?.efficiencyRating
155 if (!rating) return 'Victory'
156 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
157})
158const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
159const gameSummary = computed(() => gameStore.gameSummary)
160const timeline = computed(() => gameStore.timelineEvents)
161const chronicle = computed(() => gameStore.chronicle)
162const chronicleLoading = computed(() => gameStore.chronicleLoading)
163 
164// The keepsake: the player's verbatim messages, paired with whom/when they reached.
165const dispatches = computed(() => {
166 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
167 const events = gameStore.timelineEvents
168 return users.map((m, i) => ({
169 text: m.text,
170 figure: m.figureName || events[i]?.figureName || 'the past',
171 era: events[i]?.era || ''
172 }))
173})
174 
175// The page owns the FULL reset (store + figure input + composer), so Play Again
176// emits instead of resetting the store directly — the two reset paths used to
177// diverge here, leaving the previous run's figure stranded in the input.
178const emit = defineEmits<{ 'play-again': [] }>()
179const playAgain = () => {
180 emit('play-again')
182</script>
183 
184<style scoped>
185/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
186 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
187.end-screen { background: var(--rv-bg); animation: end-in var(--rv-dur-3) var(--rv-ease) both; }
188@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
189@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
190.end--win {
191 background:
192 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-accent) 13%, transparent), transparent 68%),
193 var(--rv-bg);
195.end--loss {
196 background:
197 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-bad) 6%, transparent), transparent 60%),
198 var(--rv-tint);
200 
201/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
202 the loss seal is a cold, flat stamp. */
203.verdict-seal {
204 display: inline-grid;
205 place-items: center;
206 width: 60px;
207 height: 60px;
208 border-radius: 50%;
209 border: 2px solid;
210 font-size: 1.6rem;
211 line-height: 1;
213.verdict-seal.is-win {
214 color: var(--rv-accent);
215 border-color: var(--rv-accent);
216 box-shadow:
217 0 0 0 5px color-mix(in srgb, var(--rv-accent) 12%, transparent),
218 0 0 30px color-mix(in srgb, var(--rv-accent) 32%, transparent);
219 animation: seal-strike 0.6s var(--rv-ease-spring) both;
221.verdict-seal.is-loss {
222 color: var(--rv-faint);
223 border-color: var(--rv-line);
224 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--rv-faint) 8%, transparent);
226 
227@keyframes seal-strike {
228 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
229 60% { transform: scale(1.12) rotate(3deg); }
230 100% { opacity: 1; transform: scale(1) rotate(0); }
232@media (prefers-reduced-motion: reduce) {
233 .verdict-seal.is-win { animation: none; }
235</style>

The mirror, and what's deliberately left alone

RunView replays a saved run read-only, and it mirrors the end screen's epilogue markup exactly — so it carried the same cap and gets the same one-line fix.

The retrospective's epilogue article, now uncapped to match EndGameScreen.

components/RunView.vue · 114 lines
components/RunView.vue114 lines · Vue
⋯ 29 lines hidden (lines 1–29)
1<template>
2 <!-- A finished run, read-only — the same verdict / epilogue / ledger the end screen
3 shows, but rendered from a saved snapshot rather than the live store, so a past
4 run reads identically to the moment it ended. Verdict strings, glyphs, and the
5 rv-* tokens are deliberately the same as EndGameScreen's. -->
6 <div data-testid="run-view" class="run-view" :class="isVictory ? 'run--win' : 'run--loss'">
7 <!-- Verdict masthead -->
8 <div class="text-center border-b rv-line pb-6">
9 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
10 <h2 v-if="isVictory" data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3">History Rewritten</h2>
11 <h2 v-else data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3">The Timeline Held</h2>
12 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
13 <span class="rv-muted inline-flex items-center gap-1.5">
14 <span aria-hidden="true">{{ snapshot.objective.icon }}</span>
15 <strong data-testid="run-objective-title" class="font-semibold">{{ snapshot.objective.title || 'Historical Mission' }}</strong>
16 </span>
17 <span class="rv-hair-c" aria-hidden="true">·</span>
18 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
19 <span data-testid="run-progress">{{ snapshot.objectiveProgress }}%</span> rewritten
20 </span>
21 </p>
22 
23 <div v-if="snapshot.efficiency" data-testid="run-efficiency" class="mt-3 rv-mono text-sm">
24 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
25 <span class="rv-muted"> · {{ snapshot.efficiency.messagesUsed }}/{{ snapshot.totalMessages }} used<span v-if="snapshot.efficiency.messagesSaved > 0"> · {{ snapshot.efficiency.messagesSaved }} saved</span></span>
26 <span v-if="snapshot.efficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
27 </div>
28 </div>
29 
30 <!-- The Chronicle's final telling — the epilogue -->
31 <div v-if="snapshot.chronicle" data-testid="run-chronicle" class="mt-6">
32 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
33 <article>
34 <h4 data-testid="run-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ snapshot.chronicle.title }}</h4>
35 <p v-for="(para, i) in snapshot.chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
36 </article>
37 </div>
⋯ 77 lines hidden (lines 38–114)
38 
39 <!-- The words you sent — the keepsake -->
40 <div v-if="snapshot.dispatches.length" data-testid="run-dispatches" class="mt-7">
41 <span class="rv-label rv-label--rule">The words you sent</span>
42 <ul class="space-y-3.5">
43 <li v-for="(d, i) in snapshot.dispatches" :key="i">
44 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
45 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
46 </li>
47 </ul>
48 </div>
49 
50 <!-- Stats -->
51 <div data-testid="run-summary" class="mt-7">
52 <span class="rv-label rv-label--rule">How it played out</span>
53 <div class="flex flex-wrap gap-10">
54 <div data-testid="run-message-efficiency">
55 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ snapshot.messagesUsed }}</div>
56 <div class="rv-label mt-1">{{ snapshot.messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
57 </div>
58 <div v-if="snapshot.bestRoll" data-testid="run-best-roll">
59 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ snapshot.bestRoll.diceRoll }}</div>
60 <div class="rv-label mt-1">{{ snapshot.messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ snapshot.bestRoll.diceOutcome }}</div>
61 </div>
62 <div v-if="snapshot.worstRoll && snapshot.messagesUsed >= 2" data-testid="run-worst-roll">
63 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ snapshot.worstRoll.diceRoll }}</div>
64 <div class="rv-label mt-1">worst roll · {{ snapshot.worstRoll.diceOutcome }}</div>
65 </div>
66 </div>
67 </div>
68 
69 <!-- The history you wrote -->
70 <div v-if="snapshot.timeline.length > 0" data-testid="run-timeline" class="mt-6">
71 <span class="rv-label rv-label--rule">The history you wrote</span>
72 <ol>
73 <li v-for="event in snapshot.timeline" :key="event.id" class="flex items-start justify-between gap-3 py-2 border-b rv-line last:border-b-0">
74 <div class="min-w-0">
75 <p class="rv-fg text-sm font-medium">{{ event.headline }}</p>
76 <p class="rv-faint text-[11px] rv-mono">{{ event.era }} · {{ event.figureName }}</p>
77 </div>
78 <span class="rv-mono text-sm font-bold shrink-0" :class="deltaClass(event.progressChange)">
79 {{ event.progressChange > 0 ? '+' : '' }}{{ event.progressChange }}%
80 </span>
81 </li>
82 </ol>
83 </div>
84 </div>
85</template>
86 
87<script setup lang="ts">
88/**
89 * RunView — a read-only render of a saved run (a RunSnapshot). It mirrors
90 * EndGameScreen's verdict / epilogue / ledger, but reads everything from the passed
91 * snapshot rather than the live store, so it can replay a past run (and later, a
92 * shared one). Intentionally self-contained: no store coupling, no AI, no controls.
93 */
94import { computed } from 'vue'
95import type { RunSnapshot } from '~/utils/run-snapshot'
96 
97const props = defineProps<{ snapshot: RunSnapshot }>()
98 
99const isVictory = computed(() => props.snapshot.status === 'victory')
100 
101// The verdict never deflates: a full-message win reads "Hard-won," not "Standard"
102// (matches EndGameScreen's phrasing exactly).
103const victoryPhrase = computed(() => {
104 const rating = props.snapshot.efficiency?.efficiencyRating
105 if (!rating) return 'Victory'
106 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
107})
108 
109function deltaClass(change: number) {
110 if (change > 0) return 'rv-ok'
111 if (change < 0) return 'rv-bad'
112 return 'rv-muted'
114</script>

The regression tests

Each component spec gains one discriminating test: it renders the epilogue and asserts the article carries no max-w-[62ch] class. These pin the exact thing that changed — re-adding the cap to either component turns the suite red — without padding coverage. The full suite stays green (817 passed) and nuxt typecheck is clean.

Mounts a victory state with a chronicle, then asserts the epilogue body fills the column (no 62ch cap).

tests/unit/components/EndGameScreen.spec.ts · 205 lines
tests/unit/components/EndGameScreen.spec.ts205 lines · TypeScript
⋯ 143 lines hidden (lines 1–143)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import EndGameScreen from '../../../components/EndGameScreen.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7import type { GameObjective } from '../../../server/utils/objectives'
8import type { Message } from '../../../stores/game'
9 
10const OBJ: GameObjective = {
11 title: 'Reach the Moon by 1900',
12 description: 'Leave Earth half a century early.',
13 era: '19th century',
14 icon: '🚀'
16 
17function resolvedTurn(diceRoll: number, diceOutcome: DiceOutcome, progressChange: number): Message {
18 return {
19 text: 'reply', sender: 'ai', figureName: 'Tesla', timestamp: new Date(),
20 diceRoll, diceOutcome, progressChange, timelineImpact: 'history shifts'
21 }
23function prompt(): Message {
24 return { text: 'prompt', sender: 'user', figureName: 'Tesla', timestamp: new Date() }
26 
27describe('EndGameScreen', () => {
28 let gameStore: ReturnType<typeof useGameStore>
29 
30 beforeEach(() => {
31 setActivePinia(createPinia())
32 gameStore = useGameStore()
33 })
34 
35 describe('Victory', () => {
36 beforeEach(() => {
37 gameStore.gameStatus = 'victory'
38 gameStore.objectiveProgress = 100
39 gameStore.currentObjective = OBJ
40 gameStore.remainingMessages = 2
41 })
42 
43 it('shows a victory headline', () => {
44 const v = mount(EndGameScreen).find('[data-testid="victory-message"]')
45 expect(v.exists()).toBe(true)
46 expect(v.text()).toContain('History Rewritten')
47 })
48 
49 it('shows the objective and final progress', () => {
50 const w = mount(EndGameScreen)
51 expect(w.find('[data-testid="objective-title"]').text()).toContain('Reach the Moon by 1900')
52 expect(w.find('[data-testid="final-progress"]').text()).toContain('100%')
53 })
54 
55 it('shows efficiency with messages saved', () => {
56 const e = mount(EndGameScreen).find('[data-testid="efficiency-display"]')
57 expect(e.exists()).toBe(true)
58 expect(e.text()).toContain('saved')
59 })
60 })
61 
62 describe('Defeat', () => {
63 beforeEach(() => {
64 gameStore.gameStatus = 'defeat'
65 gameStore.objectiveProgress = 45
66 gameStore.currentObjective = OBJ
67 gameStore.remainingMessages = 0
68 })
69 
70 it('shows a defeat headline', () => {
71 const d = mount(EndGameScreen).find('[data-testid="defeat-message"]')
72 expect(d.exists()).toBe(true)
73 expect(d.text()).toContain('Timeline Held')
74 })
75 
76 it('shows final progress below 100%', () => {
77 expect(mount(EndGameScreen).find('[data-testid="final-progress"]').text()).toContain('45%')
78 })
79 
80 it('explains the loss', () => {
81 expect(mount(EndGameScreen).find('[data-testid="defeat-explanation"]').exists()).toBe(true)
82 })
83 })
84 
85 describe('Summary stats — derived from resolved AI turns', () => {
86 beforeEach(() => {
87 gameStore.gameStatus = 'victory'
88 gameStore.remainingMessages = 3 // 2 used
89 gameStore.messageHistory = [
90 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 25),
91 prompt(), resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75)
92 ]
93 })
94 
95 it('renders the summary block', () => {
96 expect(mount(EndGameScreen).find('[data-testid="game-summary"]').exists()).toBe(true)
97 })
98 
99 it('highlights the best roll from the AI turns', () => {
100 const best = mount(EndGameScreen).find('[data-testid="best-roll"]')
101 expect(best.exists()).toBe(true)
102 expect(best.text()).toContain('20')
103 })
104 
105 it('highlights the worst roll from the AI turns', () => {
106 gameStore.messageHistory[1] = resolvedTurn(2, DiceOutcome.CRITICAL_FAILURE, -10)
107 const worst = mount(EndGameScreen).find('[data-testid="worst-roll"]')
108 expect(worst.exists()).toBe(true)
109 expect(worst.text()).toContain('2')
110 })
111 
112 it('shows the number of messages sent', () => {
113 const eff = mount(EndGameScreen).find('[data-testid="message-efficiency"]')
114 expect(eff.exists()).toBe(true)
115 expect(eff.text()).toContain('2')
116 })
117 })
118 
119 // The "history you wrote" renders through the shared Spine (TimelineLedger),
120 // not a bespoke list — so the verdict's history reads exactly like the in-game
121 // timeline (valence dots, badges, the Δ equation), per issue #84.
122 describe('The history you wrote — the shared Spine', () => {
123 beforeEach(() => {
124 gameStore.gameStatus = 'victory'
125 gameStore.addTimelineEvent({
126 figureName: 'Tesla', era: '1890s',
127 headline: 'The grid hums to life', detail: 'Alternating current spreads.',
128 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 25
129 })
130 })
131 
132 it('renders via the shared TimelineLedger, not the old hand-rolled list', () => {
133 const w = mount(EndGameScreen)
134 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
135 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true)
136 // The bespoke block folded into the Spine's testid.
137 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false)
138 })
139 
140 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
141 expect(mount(EndGameScreen).find('.spine-now').exists()).toBe(false)
142 })
143 })
144 
145 // The Chronicle epilogue fills the full debrief column like its siblings (the
146 // dispatches / stats / timeline). It used to carry an inner max-w-[62ch] measure
147 // that read as ~2/3 width next to those full-width sections (#109).
148 describe('Chronicle epilogue', () => {
149 beforeEach(() => {
150 gameStore.gameStatus = 'victory'
151 gameStore.chronicle = { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
152 })
153 
154 it('renders the epilogue body', () => {
155 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
156 expect(body.exists()).toBe(true)
157 expect(body.text()).toContain('It did not burn.')
158 })
159 
160 it('fills the column — no max-w-[62ch] cap', () => {
161 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
162 expect(body.classes()).not.toContain('max-w-[62ch]')
163 })
164 })
⋯ 41 lines hidden (lines 165–205)
165 
166 describe('Play again', () => {
167 beforeEach(() => { gameStore.gameStatus = 'victory' })
168 
169 it('offers a restart', () => {
170 const btn = mount(EndGameScreen).find('[data-testid="play-again-button"]')
171 expect(btn.exists()).toBe(true)
172 expect(btn.text()).toContain('new timeline')
173 })
174 
175 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
176 const wrapper = mount(EndGameScreen)
177 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
178 
179 // The page owns the FULL reset (store + figure input + composer); the
180 // old direct store reset left the previous run's figure in the input.
181 expect(wrapper.emitted('play-again')).toHaveLength(1)
182 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
183 })
184 })
185 
186 describe('Edge cases', () => {
187 it('handles a missing objective', () => {
188 gameStore.gameStatus = 'victory'
189 gameStore.currentObjective = null
190 expect(() => mount(EndGameScreen)).not.toThrow()
191 })
192 
193 it('does not render while still playing', () => {
194 gameStore.gameStatus = 'playing'
195 expect(mount(EndGameScreen).find('[data-testid="end-game-screen"]').exists()).toBe(false)
196 })
197 
198 it('exposes dialog semantics', () => {
199 gameStore.gameStatus = 'victory'
200 const screen = mount(EndGameScreen).find('[data-testid="end-game-screen"]')
201 expect(screen.attributes('role')).toBe('dialog')
202 expect(screen.attributes('aria-labelledby')).toBeTruthy()
203 })
204 })
205})

The same guard for the retrospective, selecting the article inside the run-chronicle block.

tests/unit/components/RunView.spec.ts · 72 lines
tests/unit/components/RunView.spec.ts72 lines · TypeScript
⋯ 49 lines hidden (lines 1–49)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect } from 'vitest'
3import RunView from '../../../components/RunView.vue'
4import { DiceOutcome } from '../../../utils/dice'
5import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '../../../utils/run-snapshot'
6 
7/**
8 * RunView renders a saved run read-only, from the snapshot alone — no store. It must
9 * reproduce the end screen's verdict / epilogue / ledger so a past run reads exactly
10 * as it did when it ended.
11 */
12function victory(): RunSnapshot {
13 return {
14 version: RUN_SNAPSHOT_VERSION,
15 runId: '11111111-1111-4111-8111-111111111111',
16 status: 'victory',
17 objective: { title: 'Save the Library', description: 'Keep the scrolls.', era: 'Antiquity', icon: '📜' },
18 objectiveProgress: 100,
19 messagesUsed: 3,
20 totalMessages: 5,
21 bestRoll: { diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS },
22 worstRoll: { diceRoll: 6, diceOutcome: DiceOutcome.FAILURE },
23 efficiency: { messagesSaved: 2, messagesUsed: 3, efficiencyPercentage: 40, efficiencyRating: 'Great', isEarlyVictory: true },
24 dispatches: [{ text: 'Hide the scrolls.', figure: 'Hypatia', era: 'Antiquity' }],
25 timeline: [{ id: 'evt-1', figureName: 'Hypatia', era: 'Antiquity', headline: 'The scrolls are hidden', detail: 'A copy survives.', diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 60, valence: 'positive' }],
26 chronicle: { title: 'The Library Stands', paragraphs: ['It did not burn.', 'The knowledge endured.'] }
27 }
29 
30function defeat(): RunSnapshot {
31 return { ...victory(), status: 'defeat', objectiveProgress: 40, efficiency: null, bestRoll: null, worstRoll: null }
33 
34describe('RunView', () => {
35 it('renders a victory verdict, objective, and final progress', () => {
36 const w = mount(RunView, { props: { snapshot: victory() } })
37 expect(w.find('[data-testid="run-verdict"]').text()).toContain('History Rewritten')
38 expect(w.find('[data-testid="run-objective-title"]').text()).toContain('Save the Library')
39 expect(w.find('[data-testid="run-progress"]').text()).toContain('100%')
40 })
41 
42 it('renders the dispatches, timeline, and chronicle epilogue', () => {
43 const w = mount(RunView, { props: { snapshot: victory() } })
44 expect(w.find('[data-testid="run-dispatches"]').text()).toContain('Hide the scrolls.')
45 expect(w.find('[data-testid="run-timeline"]').text()).toContain('The scrolls are hidden')
46 const chron = w.find('[data-testid="run-chronicle"]')
47 expect(chron.find('[data-testid="run-chronicle-title"]').text()).toContain('The Library Stands')
48 expect(chron.text()).toContain('The knowledge endured.')
49 })
50 
51 // The epilogue fills the column like the dispatches / stats / timeline — it used
52 // to carry an inner max-w-[62ch] measure that read as ~2/3 width beside them (#109).
53 it('renders the chronicle epilogue at full column width — no max-w-[62ch] cap', () => {
54 const article = mount(RunView, { props: { snapshot: victory() } })
55 .find('[data-testid="run-chronicle"] article')
56 expect(article.exists()).toBe(true)
57 expect(article.classes()).not.toContain('max-w-[62ch]')
58 })
59 
⋯ 13 lines hidden (lines 60–72)
60 it('shows the victory efficiency block on a win', () => {
61 const e = mount(RunView, { props: { snapshot: victory() } }).find('[data-testid="run-efficiency"]')
62 expect(e.exists()).toBe(true)
63 expect(e.text()).toContain('saved')
64 })
65 
66 it('renders a defeat verdict and no efficiency block', () => {
67 const w = mount(RunView, { props: { snapshot: defeat() } })
68 expect(w.find('[data-testid="run-verdict"]').text()).toContain('The Timeline Held')
69 expect(w.find('[data-testid="run-efficiency"]').exists()).toBe(false)
70 expect(w.find('[data-testid="run-progress"]').text()).toContain('40%')
71 })
72})