What changed, and why

The end screen — the victory/defeat verdict — closes with a section called "the history you wrote": every change the player made to the timeline, in order. It was a hand-rolled flat list. Each row showed a headline, an "era · figure" line, and a bare signed %.

During play, that same data (gameStore.timelineEvents) is rendered by the game's signature object: the Spine (TimelineLedger). The Spine is a valence-colored dot-axis you scroll and tap, carrying the roll, the per-node Δ equation, and the ⚡ anachronism / ✒ craft / ⚑ staked marks, with a detail strip below. So the run's history read richer during play than it did at the verdict, where the whole game's payoff should land hardest.

This change drops the bespoke list and renders the Spine at the verdict instead. The blast radius is small: three files. The end screen swaps in <TimelineLedger readonly />; the Spine gains one optional readonly prop that adapts it to a finished run; and two tests pin the new behavior. In-game usage passes nothing, so the board's Spine is unchanged.

The end screen now renders the Spine

The replacement is nearly a drop-in. TimelineLedger reads the store directly and takes no required props, and the end screen runs against the same populated store, so rendering it is one tag. The v-if="timeline.length > 0" guard stays (no empty Spine at the verdict), and the mt-6 spacing matches the section rhythm above it.

The hand-rolled <ol> is gone; the Spine renders the history instead.

components/EndGameScreen.vue · 235 lines
components/EndGameScreen.vue235 lines · Vue
⋯ 76 lines hidden (lines 1–76)
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">
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" class="max-w-[62ch]">
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>
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>
⋯ 151 lines hidden (lines 85–235)
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 old block carried data-testid="final-timeline" and a local deltaClass helper that colored each row's %. Both are removed: the testid folds into the Spine's own data-testid="timeline-ledger", and deltaClass had no other caller, so it's deleted rather than left dangling.

A read-only mode for the Spine

The Spine is built for a live run: it ends the axis with a "▷ now · your move" present marker, leans into a "the timeline trembles…" state while a move resolves, and frames its lead in the present tense ("the timeline you're rewriting"). None of that fits a finished run. So TimelineLedger gains one optional prop, readonly, that turns those live affordances off.

The prop, and the three places it gates: the lead copy, the loading empty state, and the live present marker.

components/TimelineLedger.vue · 202 lines
components/TimelineLedger.vue202 lines · Vue
⋯ 7 lines hidden (lines 1–7)
1<template>
2 <!-- THE SPINE: history as a horizontal era-axis you travel left→right. -->
3 <div data-testid="timeline-ledger">
4 <div class="border-b rv-line pb-1.5 mb-3">
5 <div class="flex items-center justify-between gap-3">
6 <span class="rv-label">The Spine</span>
7 <span class="rv-mono rv-faint text-[11px] normal-case tracking-normal">
8 {{ events.length }} {{ events.length === 1 ? 'change' : 'changes' }}<template v-if="anachCount"> · ⚡{{ anachCount }}</template><span v-if="events.length" class="ml-2">scroll →</span>
9 </span>
10 </div>
11 <p class="rv-faint text-[11px] mt-0.5">{{ props.readonly ? 'the history you wrote' : "the timeline you're rewriting" }} — every change, in the order you made it<template v-if="events.length"> · <span class="rv-accent">tap any node to inspect</span></template></p>
12 </div>
⋯ 3 lines hidden (lines 13–15)
13 
14 <!-- Empty state — but the instant a first move is resolving, it leans in rather
15 than still reading "unwritten" (the board never contradicts the act). -->
16 <div v-if="events.length === 0" data-testid="timeline-empty" class="text-center py-12">
17 <template v-if="!props.readonly && gameStore.isLoading">
18 <div class="text-4xl mb-3 opacity-50 rv-accent" aria-hidden="true"></div>
⋯ 30 lines hidden (lines 19–48)
19 <p class="rv-serif rv-fg text-lg">The timeline trembles…</p>
20 <p class="rv-faint text-xs mt-1">Your message ripples into the past.</p>
21 </template>
22 <template v-else>
23 <div class="text-4xl mb-3 opacity-40" aria-hidden="true">📜</div>
24 <p class="rv-serif rv-fg text-lg">History is still unwritten.</p>
25 <p class="rv-faint text-xs mt-1">Send your first message to bend the timeline.</p>
26 </template>
27 </div>
28 
29 <!-- The axis -->
30 <div v-else class="spine-scroll overflow-x-auto px-1 pb-2">
31 <TransitionGroup name="timeline" tag="ol" class="flex items-start min-w-full" aria-label="Timeline of changes">
32 <li v-for="event in events" :key="event.id" data-testid="timeline-event"
33 class="spine-node rv-press" :class="{ 'is-selected': event.id === selectedId }"
34 role="button" tabindex="0" @click="select(event.id)" @keydown.enter="select(event.id)" @keydown.space.prevent="select(event.id)">
35 <span class="sr-only">{{ event.headline }}{{ event.detail }} ({{ signed(event.progressChange) }})</span>
36 <span class="block text-center rv-mono text-[11px] rv-faint">{{ event.era || '—' }}</span>
37 <span class="dot-wrap" aria-hidden="true">
38 <span class="connector" />
39 <span class="rv-dot" :class="event.id === selectedId ? 'rv-dot--accent' : dotClass(event.valence)" />
40 </span>
41 <span class="block text-center rv-mono text-xs rv-fg truncate px-1">{{ event.figureName }}</span>
42 <span class="block text-center rv-mono text-[11px] mt-0.5 tabular-nums">
43 <span class="rv-faint">🎲</span>{{ event.diceRoll }}
44 <span class="delta-cell" :class="deltaClass(event.progressChange)">{{ signed(event.progressChange) }}</span><span
45 v-if="isAnachronistic(event.anachronism)" class="rv-warn ml-0.5" :aria-label="anachronismLabel(event.anachronism)" :title="anachronismHint(event.anachronism)">{{ anachPips(event.anachronism) }}</span><span
46 v-if="event.staked" class="rv-warn ml-0.5" aria-label="Staked — the last stand" :title="STAKE_HINT"></span>
47 </span>
48 </li>
49 
50 <!-- the live present — only while the run is live; the verdict has no next move -->
51 <li v-if="!props.readonly" key="now" class="spine-now" aria-hidden="true">
52 <span class="block text-center rv-mono text-[11px] rv-accent font-semibold">▷ now</span>
⋯ 54 lines hidden (lines 53–106)
53 <span class="dot-wrap"><span class="connector connector--half" /></span>
54 <span class="block text-center rv-faint text-[11px]">your move</span>
55 </li>
56 </TransitionGroup>
57 </div>
58 
59 <!-- Node-detail strip: the selected change in full (defaults to the newest).
60 The consequence headline leads — the second of the board's two focal peaks. -->
61 <div v-if="selected" data-testid="node-detail" class="border-t rv-line pt-2.5 mt-2">
62 <p class="rv-serif rv-fg text-lg lg:text-xl font-semibold leading-snug">{{ selected.headline }}</p>
63 <p class="rv-muted text-xs lg:text-sm mt-1 leading-relaxed max-w-2xl">{{ selected.detail }}</p>
64 <div class="flex items-center gap-x-2 gap-y-1 flex-wrap text-[11px] rv-mono mt-1.5 tabular-nums">
65 <span class="inline-flex items-center gap-1.5">
66 <span class="rv-dot" :class="dotClass(selected.valence)" aria-hidden="true" />
67 <span class="rv-faint">{{ selected.era || '—' }} · {{ selected.figureName }}</span>
68 </span>
69 <span class="rv-hair-c">·</span>
70 <span class="rv-muted">🎲 {{ selected.diceRoll }} · {{ selected.diceOutcome }}</span>
71 <span v-if="selected.craft && selected.craft !== 'sound'" class="rv-hair-c">·</span>
72 <SymbolHint v-if="selected.craft && selected.craft !== 'sound'" :text="CRAFT_HINT[selected.craft]">
73 <span data-testid="craft-badge"
74 class="font-semibold" :class="selected.craft === 'masterful' || selected.craft === 'sharp' ? 'rv-ok' : 'rv-warn'">{{ CRAFT_LABEL[selected.craft] }}</span>
75 </SymbolHint>
76 <span class="rv-hair-c">·</span>
77 <span class="font-bold" :class="deltaClass(selected.progressChange)">{{ signed(selected.progressChange) }}%</span>
78 <!-- The wager's arithmetic, in the open: base → amplified/staked final. -->
79 <span v-if="selected.baseProgressChange !== undefined && selected.baseProgressChange !== selected.progressChange"
80 data-testid="swing-equation" class="rv-faint">({{ signed(selected.baseProgressChange) }}{{ signed(selected.progressChange) }})</span>
81 <SymbolHint v-if="isAnachronistic(selected.anachronism)" :text="anachronismHint(selected.anachronism)">
82 <span data-testid="anachronism-badge" class="rv-warn font-semibold ml-1">{{ anachronismLabel(selected.anachronism) }}</span>
83 </SymbolHint>
84 <SymbolHint v-if="selected.staked" :text="STAKE_HINT">
85 <span data-testid="staked-badge" class="rv-warn font-semibold ml-1">⚑ staked</span>
86 </SymbolHint>
87 </div>
88 </div>
89 </div>
90</template>
91 
92<script setup lang="ts">
93/**
94 * TimelineLedger — the Spine. Each resolved turn is a node on a horizontal era-axis
95 * (valence dot + mono year/figure/roll/Δ + ⚡), newest on the right with a ▷now marker;
96 * clicking a node snaps its full payload into the node-detail strip, which defaults to
97 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
98 * only prose lives in the strip. (Re-skin only — the store data is unchanged.)
99 */
100import { computed, ref, watch } from 'vue'
101import { useGameStore } from '~/stores/game'
102import type { Valence } from '~/stores/game'
103import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
104import { CRAFT_HINT, CRAFT_LABEL } from '~/utils/craft'
105import { STAKE_HINT } from '~/utils/swing-bands'
106 
107const gameStore = useGameStore()
108const props = defineProps<{
109 /** Verdict / past-run view (the end screen): drop the live "▷ now" present
110 * marker and the "the timeline trembles…" loading empty state, and frame the
111 * lead in past tense. In-game usage passes nothing, so its behavior is
112 * unchanged — the Spine the player scrolled and tapped renders the same. */
113 readonly?: boolean
114}>()
115const events = computed(() => gameStore.timelineEvents)
⋯ 87 lines hidden (lines 116–202)
116 
117const selectedId = ref<string | null>(null)
118// Default the detail strip to the newest change, and snap to it whenever one lands.
119watch(() => events.value.length, () => {
120 const last = events.value[events.value.length - 1]
121 if (last) selectedId.value = last.id
122}, { immediate: true })
123 
124const selected = computed(() =>
125 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
127const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
128 
129function select(id: string) { selectedId.value = id }
130function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
131function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
132function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
133function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
134/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
135function anachPips(a?: Anachronism) {
136 return a === 'impossible' ? '⚡⚡⚡' : a === 'far-ahead' ? '⚡⚡' : a === 'ahead' ? '⚡' : ''
138 
139function dotClass(v: Valence) {
140 return v === 'positive' ? 'rv-dot--ok' : v === 'negative' ? 'rv-dot--bad' : 'rv-dot--mut'
142function deltaClass(change: number) {
143 if (change > 0) return 'rv-ok'
144 if (change < 0) return 'rv-bad'
145 return 'rv-muted'
147</script>
148 
149<style scoped>
150.spine-node,
151.spine-now {
152 flex: 0 0 auto;
153 min-width: 92px;
154 padding: 2px 4px;
155 cursor: pointer;
156 border: none;
157 background: transparent;
158 scroll-snap-align: center;
160.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
161/* Reserve a stable sign slot so a column of +/− deltas stacks rule-straight. */
162.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
163.spine-now { cursor: default; }
164.spine-node:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
165 
166/* The axis line is drawn by a full-width connector behind each node's dot. */
167.dot-wrap {
168 position: relative;
169 display: flex;
170 justify-content: center;
171 align-items: center;
172 height: 16px;
173 margin: 3px 0;
175.connector {
176 position: absolute;
177 top: 50%;
178 left: 0;
179 right: 0;
180 height: 1px;
181 background: var(--rv-line);
182 transform: translateY(-50%);
184.connector--half { right: 50%; }
185.dot-wrap > .rv-dot { position: relative; z-index: 1; }
186.spine-node.is-selected .dot-wrap > .rv-dot {
187 width: 12px;
188 height: 12px;
189 box-shadow: 0 0 0 3px var(--rv-bg), 0 0 0 4px var(--rv-accent);
191.spine-node:hover .dot-wrap > .rv-dot { transform: translateY(-1px); }
192 
193/* Preserved entry/move animation (now driving the right-edge node arrival). */
194.timeline-enter-active { transition: opacity var(--rv-dur-3) var(--rv-ease), transform var(--rv-dur-3) var(--rv-ease); }
195.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
196.timeline-move { transition: transform var(--rv-dur-3) var(--rv-ease); }
197 
198@media (prefers-reduced-motion: reduce) {
199 .timeline-enter-active, .timeline-move { transition: none; }
200 .spine-node:hover .dot-wrap > .rv-dot { transform: none; }
202</style>

Three small gates, all keyed on props.readonly. The lead reads "the history you wrote" instead of "the timeline you're rewriting" (line 11). The live "the timeline trembles…" branch is skipped (line 17) — a verdict has no move in flight. And the "▷ now · your move" node is dropped (line 51) — a finished run has no next move. Tap-to-inspect stays on, so the verdict's timeline is still explorable.

The tests that pin it

Two discriminating tests, each failing on the old code and passing on the new. The first asserts the verdict renders the shared Spine (timeline-ledger + timeline-event present, the dead final-timeline absent) rather than a bespoke list. The second asserts the Spine renders read-only: no .spine-now present marker. A populated store comes from the store's own addTimelineEvent, so the test exercises the real render path.

The new describe block — render-via-Spine, and read-only.

tests/unit/components/EndGameScreen.spec.ts · 184 lines
tests/unit/components/EndGameScreen.spec.ts184 lines · TypeScript
⋯ 117 lines hidden (lines 1–117)
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 })
⋯ 41 lines hidden (lines 144–184)
144 
145 describe('Play again', () => {
146 beforeEach(() => { gameStore.gameStatus = 'victory' })
147 
148 it('offers a restart', () => {
149 const btn = mount(EndGameScreen).find('[data-testid="play-again-button"]')
150 expect(btn.exists()).toBe(true)
151 expect(btn.text()).toContain('new timeline')
152 })
153 
154 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
155 const wrapper = mount(EndGameScreen)
156 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
157 
158 // The page owns the FULL reset (store + figure input + composer); the
159 // old direct store reset left the previous run's figure in the input.
160 expect(wrapper.emitted('play-again')).toHaveLength(1)
161 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
162 })
163 })
164 
165 describe('Edge cases', () => {
166 it('handles a missing objective', () => {
167 gameStore.gameStatus = 'victory'
168 gameStore.currentObjective = null
169 expect(() => mount(EndGameScreen)).not.toThrow()
170 })
171 
172 it('does not render while still playing', () => {
173 gameStore.gameStatus = 'playing'
174 expect(mount(EndGameScreen).find('[data-testid="end-game-screen"]').exists()).toBe(false)
175 })
176 
177 it('exposes dialog semantics', () => {
178 gameStore.gameStatus = 'victory'
179 const screen = mount(EndGameScreen).find('[data-testid="end-game-screen"]')
180 expect(screen.attributes('role')).toBe('dialog')
181 expect(screen.attributes('aria-labelledby')).toBeTruthy()
182 })
183 })
184})

npx vitest run is green at 720 (the +2 here). npx nuxt typecheck is clean. The e2e win/lose specs (tests/e2e/features/end-game.spec.ts) and the mission-select timeline-ledger specs pass. An independent a11y-source pass over both changed components found no gaps — the interactive Spine nodes are keyboard-complete and named, and the dialog stays focus-trapped.