What changed, and why

On the final win/lose screen, the Chronicle epilogue looked different while it was being written than once it finished. Streaming, it was one raw paragraph capped at half the column with the title inline as plain text; settled, it was a full-width titled article with separated serif paragraphs. So the moment the telling landed it jumped in both width (~50% to 100%) and formatting.

The fix is a reuse, not a redesign: render the streaming state through the exact markup the settled epilogue already uses, sourced from the live buffer and parsed by the same helper the in-game Chronicle's live view relies on. The account now wears its final shape as it streams, so the settle is a quiet cross-fade with no reflow. Blast radius is one component (plus one new test); the settled rendering, the store, and the parser are untouched.

The streaming branch, reshaped to match the settled one

Both tellings live in the same <Transition mode="out-in">. The streaming branch (shown first) now emits the same structure as the settled branch below it: a titled <h4> with the identical classes, then a v-for of <p> paragraphs with the identical classes — no width cap. The only streaming-specific touch is the ink cursor at the writing tip, and aria-busy while it composes.

Streaming: a titled heading + separated paragraphs off liveEpilogue, ink cursor at the tip.

components/EndGameScreen.vue · 622 lines
components/EndGameScreen.vue622 lines · Vue
⋯ 117 lines hidden (lines 1–117)
1<template>
2 <div
3 v-if="
4 gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'
5 "
6 ref="dialogRef"
7 data-testid="end-game-screen"
8 role="dialog"
9 aria-modal="true"
10 :aria-labelledby="headingId"
11 class="end-screen fixed inset-0 z-50 overflow-y-auto"
12 :class="isVictory ? 'end--win' : 'end--loss'"
13 @keydown="onKeydown"
14 >
15 <div class="max-w-4xl mx-auto px-5 py-8">
16 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
17 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
18 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
19 <div class="text-center border-b ew-line pb-6">
20 <div
21 class="verdict-seal"
22 :class="isVictory ? 'is-win' : 'is-loss'"
23 aria-hidden="true"
24 >
25 {{ isVictory ? "✦" : "⊘" }}
26 </div>
27 <h2
28 v-if="isVictory"
29 :id="headingId"
30 ref="headingRef"
31 tabindex="-1"
32 data-testid="victory-message"
33 class="ew-serif text-3xl sm:text-4xl font-bold ew-fg mt-3 outline-none"
34 >
35 History Rewritten
36 </h2>
37 <h2
38 v-else
39 :id="headingId"
40 ref="headingRef"
41 tabindex="-1"
42 data-testid="defeat-message"
43 class="ew-serif text-3xl sm:text-4xl font-bold ew-muted mt-3 outline-none"
44 >
45 The Timeline Held
46 </h2>
47 <p
48 class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap"
49 >
50 <span class="ew-muted inline-flex items-center gap-1.5">
51 <span aria-hidden="true">{{
52 gameStore.currentObjective?.icon
53 }}</span>
54 <strong data-testid="objective-title" class="font-semibold">{{
55 gameStore.currentObjective?.title || "Historical Mission"
56 }}</strong>
57 </span>
58 <span class="ew-hair-c" aria-hidden="true">·</span>
59 <span
60 class="ew-mono font-bold"
61 :class="isVictory ? 'ew-ok' : 'ew-bad'"
62 >
63 <span data-testid="final-progress"
64 >{{ gameStore.objectiveProgress }}%</span
65 >
66 rewritten
67 </span>
68 </p>
69 
70 <RemixCreditLine
71 v-if="remixCredit"
72 :credit="remixCredit"
73 class="mt-2.5"
74 />
75 
76 <p
77 v-if="!isVictory"
78 data-testid="defeat-explanation"
79 class="ew-muted text-sm mt-3 max-w-prose mx-auto"
80 >
81 Your five messages are spent. What you changed remains — but it was
82 not enough, and the objective slipped away.
83 </p>
84 
85 <div
86 v-if="victoryEfficiency"
87 data-testid="efficiency-display"
88 class="mt-3 ew-mono text-sm"
89 >
90 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
91 <span class="ew-muted">
92 · {{ victoryEfficiency.messagesUsed }}/{{
93 TOTAL_MESSAGES
94 }}
95 used<span v-if="victoryEfficiency.messagesSaved > 0">
96 · {{ victoryEfficiency.messagesSaved }} saved</span
97 ></span
98 >
99 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok">
100 · 🌟 to spare</span
101 >
102 </div>
103 </div>
104 
105 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
106 still being written (epiloguePending), we show the "final account…" wait, NOT
107 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
108 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
109 pending and falls back to the prior telling, so the panel never hangs. -->
110 <div
111 v-if="chronicle || epiloguePending"
112 data-testid="end-chronicle"
113 class="mt-6"
114 >
115 <span class="ew-label ew-label--rule"
116 ><span aria-hidden="true">📖</span> The Chronicle</span
117 >
118 <Transition :name="epilogueTransition" mode="out-in">
119 <!-- The final account being WRITTEN — the SAME titled article as the settled
120 epilogue below, parsed from the live stream so it already wears its final
121 shape (full width, titled heading, separated paragraphs); an ink cursor
122 marks the writing tip. Nothing relayouts when the telling lands. Mirrors
123 Chronicle.vue's live view via the shared parser in utils/chronicle.ts. -->
124 <article
125 v-if="epiloguePending && streamingNow"
126 key="epilogue-streaming"
127 data-testid="end-chronicle-streaming"
128 aria-busy="true"
129 >
130 <h4
131 data-testid="end-chronicle-title"
132 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
133 >
134 {{ liveEpilogue.title
135 }}<span
136 v-if="!liveEpilogue.paragraphs.length"
137 class="ink-cursor"
138 aria-hidden="true"
139 />
140 </h4>
141 <p
142 v-for="(para, i) in liveEpilogue.paragraphs"
143 :key="i"
144 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
145 >
146 {{ para
147 }}<span
148 v-if="i === liveEpilogue.paragraphs.length - 1"
149 class="ink-cursor"
150 aria-hidden="true"
151 />
152 </p>
153 </article>
⋯ 469 lines hidden (lines 154–622)
154 <div
155 v-else-if="epiloguePending"
156 key="epilogue-loading"
157 data-testid="end-chronicle-loading"
158 class="ew-faint italic text-sm"
159 >
160 The chronicler sets down the final account…
161 </div>
162 <article
163 v-else-if="chronicle"
164 key="epilogue-body"
165 data-testid="end-chronicle-body"
166 >
167 <h4
168 data-testid="end-chronicle-title"
169 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
170 >
171 {{ chronicle.title }}
172 </h4>
173 <p
174 v-for="(para, i) in chronicle.paragraphs"
175 :key="i"
176 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
177 >
178 {{ para }}
179 </p>
180 </article>
181 </Transition>
182 </div>
183 
184 <!-- The words you sent — the keepsake: your actual messages into the past -->
185 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
186 <span class="ew-label ew-label--rule">The words you sent</span>
187 <ul class="space-y-3.5">
188 <li v-for="(d, i) in dispatches" :key="i">
189 <p class="ew-label">
190 to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span>
191 </p>
192 <blockquote
193 class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1"
194 >
195 "{{ d.text }}"
196 </blockquote>
197 </li>
198 </ul>
199 </div>
200 
201 <!-- Stats — derived from the real resolved turns -->
202 <div data-testid="game-summary" class="mt-7">
203 <span class="ew-label ew-label--rule">How it played out</span>
204 <div class="flex flex-wrap gap-10">
205 <!-- "Peaked at X%" — shown only when the run slipped from its high point
206 (the peak sits above the floored final %). The case it exists for is a
207 staked last dispatch that crit-fails to 0% from a real peak, where the
208 final % alone reads "as if you did nothing" (#129). A clean win never
209 shows it: its peak IS the final 100%. -->
210 <div v-if="showPeak" data-testid="peak-progress">
211 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">
212 {{ gameStore.peakProgress }}%
213 </div>
214 <div class="ew-label mt-1">peaked at</div>
215 </div>
216 <div data-testid="message-efficiency">
217 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">
218 {{ messagesUsed }}
219 </div>
220 <div class="ew-label mt-1">
221 {{ messagesUsed === 1 ? "message" : "messages" }} sent
222 </div>
223 </div>
224 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
225 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">
226 {{ gameSummary.bestRoll.diceRoll }}
227 </div>
228 <div class="ew-label mt-1">
229 {{ messagesUsed >= 2 ? "best roll" : "the roll that did it" }} ·
230 {{ gameSummary.bestRoll.diceOutcome }}
231 </div>
232 </div>
233 <div
234 v-if="gameSummary.worstRoll && messagesUsed >= 2"
235 data-testid="worst-roll"
236 >
237 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">
238 {{ gameSummary.worstRoll.diceRoll }}
239 </div>
240 <div class="ew-label mt-1">
241 worst roll · {{ gameSummary.worstRoll.diceOutcome }}
242 </div>
243 </div>
244 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
245 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
246 It names a variance-driven loss as cold dice, not bad skill. Shown once
247 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
248 that did it" above). The number borrows the same red/green/ink the
249 best/worst stats use; the tooltip carries the below-median detail. -->
250 <div
251 v-if="gameSummary.luck"
252 data-testid="luck-read"
253 :title="luckTitle"
254 >
255 <div
256 class="ew-mono text-3xl font-extrabold leading-none"
257 :class="luckClass"
258 >
259 {{ gameSummary.luck.averageRoll.toFixed(1) }}
260 </div>
261 <div class="ew-label mt-1">
262 avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}
263 </div>
264 </div>
265 </div>
266 </div>
267 
268 <!-- The history you wrote — the Spine itself, exactly as it filled in during
269 the run. Read-only here: no live "▷ now" present marker, no "the timeline
270 trembles…" empty state. Same component the player scrolled and tapped on
271 the board, so the verdict's history reads as richly as it did in play —
272 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
273 <div v-if="timeline.length > 0" class="mt-6">
274 <TimelineLedger readonly />
275 </div>
276 
277 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
278 rewrite? Post it: the public page, the unfurl, the share card. -->
279 <div v-if="canShare" class="mt-8">
280 <ShareControls :run-id="shareRunId" :share-text="shareText" />
281 </div>
282 
283 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
284 <button
285 ref="playAgainRef"
286 type="button"
287 data-testid="play-again-button"
288 class="ew-btn ew-btn--primary"
289 @click="playAgain"
290 >
291 Try a new timeline <span aria-hidden="true"></span>
292 </button>
293 <!-- The finished run just joined your saved-runs history, so the end screen
294 offers an obvious way to reach it: a secondary button beside the primary
295 "Try a new timeline" (the button-styled /runs link mirrors the
296 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
297 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
298 <NuxtLink
299 to="/runs"
300 data-testid="end-your-runs-link"
301 class="ew-btn inline-flex items-center gap-1.5"
302 >
303 <span aria-hidden="true"></span> Your runs
304 </NuxtLink>
305 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
306 <span
307 class="ew-dot"
308 :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'"
309 aria-hidden="true"
310 />
311 this timeline is written.
312 </span>
313 </div>
314 </div>
315 </div>
316</template>
317 
318<script setup lang="ts">
319/**
320 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
321 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
322 * wrote. All stats derive from the real resolved turns.
323 */
324import { ref, computed, watch, nextTick } from "vue";
325import { TOTAL_MESSAGES } from "~/utils/game-config";
326import { useGameStore } from "~/stores/game";
327import { useChronicleStore } from "~/stores/chronicle";
328import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
329import { buildShareText, type RemixCredit } from "~/utils/run-snapshot";
330import { parseLiveChronicle } from "~/utils/chronicle";
331 
332const gameStore = useGameStore();
333const chronicleStore = useChronicleStore();
334 
335// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
336// shown on the end screen the moment the replay resolves. The title is omitted (the
337// objective is already in the masthead above), so it reads "Remixed from <source>".
338const remixCredit = computed<RemixCredit | null>(() =>
339 gameStore.replayState
340 ? {
341 attribution: gameStore.replayState.sourceAttribution,
342 objectiveTitle: "",
343 sourceToken: gameStore.replayState.sourceToken,
344 }
345 : null,
346);
347const reduced = usePrefersReducedMotion();
348 
349const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`;
350 
351// Modal focus management: it's a true takeover (aria-modal), so when it opens we
352// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
353// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
354// the heading — not the bottom Play Again button — also means the screen opens at
355// the top: the verdict and the Chronicle epilogue are the first things seen, not
356// scrolled past below the fold.
357const dialogRef = ref<HTMLElement | null>(null);
358const headingRef = ref<HTMLElement | null>(null);
359const playAgainRef = ref<HTMLElement | null>(null);
360let prevFocus: HTMLElement | null = null;
361 
362watch(
363 () => gameStore.gameStatus,
364 async (s) => {
365 if (typeof document === "undefined") return;
366 if (s === "victory" || s === "defeat") {
367 prevFocus = document.activeElement as HTMLElement | null;
368 await nextTick();
369 if (dialogRef.value) dialogRef.value.scrollTop = 0;
370 headingRef.value?.focus();
371 } else if (prevFocus) {
372 prevFocus.focus?.();
373 prevFocus = null;
374 }
375 },
376);
377 
378function onKeydown(e: KeyboardEvent) {
379 if (e.key !== "Tab" || !dialogRef.value) return;
380 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
381 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
382 );
383 if (!focusables.length) return;
384 const first = focusables[0];
385 const last = focusables[focusables.length - 1];
386 const active = document.activeElement;
387 if (e.shiftKey && active === first) {
388 e.preventDefault();
389 last.focus();
390 } else if (!e.shiftKey && active === last) {
391 e.preventDefault();
392 first.focus();
393 }
395 
396const isVictory = computed(() => gameStore.gameStatus === "victory");
397const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency());
398 
399// The verdict never deflates: spending every message is reframed as "Hard-won," not
400// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
401const victoryPhrase = computed(() => {
402 const rating = victoryEfficiency.value?.efficiencyRating;
403 if (!rating) return "Victory";
404 return rating === "Standard" ? "Hard-won victory" : `${rating} victory`;
405});
406const messagesUsed = computed(
407 () => TOTAL_MESSAGES - gameStore.remainingMessages,
408);
409const gameSummary = computed(() => gameStore.gameSummary);
410 
411// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
412// the win green, an even run the neutral ink — the same at-a-glance colour language
413// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
414// dice," never the player's craft. The tooltip adds the below-median detail the
415// compact label leaves out.
416const luckClass = computed(() => {
417 const v = gameSummary.value.luck?.verdict;
418 return v === "cold" ? "ew-bad" : v === "hot" ? "ew-ok" : "ew-fg";
419});
420const luckPhrase = computed(() => {
421 const v = gameSummary.value.luck?.verdict;
422 return v === "cold"
423 ? "the dice ran cold"
424 : v === "hot"
425 ? "the dice ran hot"
426 : "the dice ran even";
427});
428const luckTitle = computed(() => {
429 const l = gameSummary.value.luck;
430 if (!l) return undefined;
431 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`;
432});
433 
434// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
435// the peak above the final %. The case it answers is a staked last dispatch that
436// crit-fails to 0% from a real peak, where the floored final % alone would read "as
437// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
438const showPeak = computed(
439 () => gameStore.peakProgress > gameStore.objectiveProgress,
440);
441 
442// Share affordance (issue #88): only for a run that's been durably saved (so there's a
443// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
444const canShare = computed(
445 () => gameStore.runSnapshotSaved && !!gameStore.runId,
446);
447const shareRunId = computed(() => gameStore.runId ?? "");
448const shareText = computed(() =>
449 buildShareText(
450 gameStore.currentObjective?.title ?? "",
451 isVictory.value ? "victory" : "defeat",
452 gameStore.objectiveProgress,
453 ),
454);
455const timeline = computed(() => gameStore.timelineEvents);
456const chronicle = computed(() => chronicleStore.chronicle);
457// The epilogue (terminal telling) is still being written — show the wait, never the
458// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
459// under prefers-reduced-motion (the empty name disables the Transition's classes).
460const epiloguePending = computed(() => chronicleStore.epiloguePending);
461const epilogueTransition = computed(() => (reduced.value ? "" : "epilogue"));
462// The epilogue is being written right now — show the live buffer (only when its
463// stream narrates the live ledger, never a superseded prior stream).
464const streamingNow = computed(
465 () =>
466 chronicleStore.chronicleStreaming &&
467 chronicleStore.chronicleStreamLen === gameStore.timelineEvents.length &&
468 chronicleStore.chronicleStream.length > 0,
469);
470// The epilogue mid-stream, parsed into the settled {title, paragraphs} shape so the
471// live account already wears its final form — full width, titled heading, separated
472// paragraphs — and nothing relayouts when the terminal telling lands. Same parser and
473// intent as Chronicle.vue's live view (utils/chronicle.ts).
474const liveEpilogue = computed(() =>
475 parseLiveChronicle(chronicleStore.chronicleStream),
476);
477 
478// The keepsake: the player's verbatim messages, paired with whom/when they reached.
479const dispatches = computed(() => {
480 const users = gameStore.messageHistory.filter((m) => m.sender === "user");
481 const events = gameStore.timelineEvents;
482 return users.map((m, i) => ({
483 text: m.text,
484 figure: m.figureName || events[i]?.figureName || "the past",
485 era: events[i]?.era || "",
486 }));
487});
488 
489// The page owns the FULL reset (store + figure input + composer), so Play Again
490// emits instead of resetting the store directly — the two reset paths used to
491// diverge here, leaving the previous run's figure stranded in the input.
492const emit = defineEmits<{ "play-again": [] }>();
493const playAgain = () => {
494 emit("play-again");
495};
496</script>
497 
498<style scoped>
499/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
500 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
501 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
502 instant swap with no classes attached. */
503.epilogue-enter-active,
504.epilogue-leave-active {
505 transition: opacity var(--ew-dur-3) var(--ew-ease);
507.epilogue-enter-from,
508.epilogue-leave-to {
509 opacity: 0;
511 
512/* The epilogue being written — an ink cursor at the writing tip (the prose itself
513 reuses the settled telling's serif article, so it needs no separate type styling). */
514.ink-cursor {
515 display: inline-block;
516 width: 2px;
517 height: 1.05em;
518 margin-left: 1px;
519 vertical-align: text-bottom;
520 background: var(--ew-accent);
521 animation: ink-blink 1s steps(2, start) infinite;
523@keyframes ink-blink {
524 0%,
525 100% {
526 opacity: 1;
527 }
528 50% {
529 opacity: 0;
530 }
532@media (prefers-reduced-motion: reduce) {
533 .ink-cursor {
534 animation: none;
535 opacity: 0.7;
536 }
538 
539/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
540 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
541.end-screen {
542 background: var(--ew-bg);
543 animation: end-in var(--ew-dur-3) var(--ew-ease) both;
545@keyframes end-in {
546 from {
547 opacity: 0;
548 }
549 to {
550 opacity: 1;
551 }
553@media (prefers-reduced-motion: reduce) {
554 .end-screen {
555 animation: none;
556 }
558.end--win {
559 background:
560 radial-gradient(
561 135% 70% at 50% -12%,
562 color-mix(in srgb, var(--ew-accent) 13%, transparent),
563 transparent 68%
564 ),
565 var(--ew-bg);
567.end--loss {
568 background:
569 radial-gradient(
570 135% 70% at 50% -12%,
571 color-mix(in srgb, var(--ew-bad) 6%, transparent),
572 transparent 60%
573 ),
574 var(--ew-bg);
576 
577/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
578 the loss seal is a cold, flat stamp. */
579.verdict-seal {
580 display: inline-grid;
581 place-items: center;
582 width: 60px;
583 height: 60px;
584 border-radius: 50%;
585 border: 2px solid;
586 font-size: 1.6rem;
587 line-height: 1;
589.verdict-seal.is-win {
590 color: var(--ew-accent);
591 border-color: var(--ew-accent);
592 box-shadow:
593 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
594 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
595 animation: seal-strike 0.6s var(--ew-ease-spring) both;
597.verdict-seal.is-loss {
598 color: var(--ew-faint);
599 border-color: var(--ew-line);
600 box-shadow: inset 0 0 0 4px
601 color-mix(in srgb, var(--ew-faint) 8%, transparent);
603 
604@keyframes seal-strike {
605 0% {
606 opacity: 0;
607 transform: scale(0.5) rotate(-12deg);
608 }
609 60% {
610 transform: scale(1.12) rotate(3deg);
611 }
612 100% {
613 opacity: 1;
614 transform: scale(1) rotate(0);
615 }
617@media (prefers-reduced-motion: reduce) {
618 .verdict-seal.is-win {
619 animation: none;
620 }
622</style>

Settled: the same <h4> and <p> classes. The two now render identically.

components/EndGameScreen.vue · 622 lines
components/EndGameScreen.vue622 lines · Vue
⋯ 161 lines hidden (lines 1–161)
1<template>
2 <div
3 v-if="
4 gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'
5 "
6 ref="dialogRef"
7 data-testid="end-game-screen"
8 role="dialog"
9 aria-modal="true"
10 :aria-labelledby="headingId"
11 class="end-screen fixed inset-0 z-50 overflow-y-auto"
12 :class="isVictory ? 'end--win' : 'end--loss'"
13 @keydown="onKeydown"
14 >
15 <div class="max-w-4xl mx-auto px-5 py-8">
16 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
17 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
18 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
19 <div class="text-center border-b ew-line pb-6">
20 <div
21 class="verdict-seal"
22 :class="isVictory ? 'is-win' : 'is-loss'"
23 aria-hidden="true"
24 >
25 {{ isVictory ? "✦" : "⊘" }}
26 </div>
27 <h2
28 v-if="isVictory"
29 :id="headingId"
30 ref="headingRef"
31 tabindex="-1"
32 data-testid="victory-message"
33 class="ew-serif text-3xl sm:text-4xl font-bold ew-fg mt-3 outline-none"
34 >
35 History Rewritten
36 </h2>
37 <h2
38 v-else
39 :id="headingId"
40 ref="headingRef"
41 tabindex="-1"
42 data-testid="defeat-message"
43 class="ew-serif text-3xl sm:text-4xl font-bold ew-muted mt-3 outline-none"
44 >
45 The Timeline Held
46 </h2>
47 <p
48 class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap"
49 >
50 <span class="ew-muted inline-flex items-center gap-1.5">
51 <span aria-hidden="true">{{
52 gameStore.currentObjective?.icon
53 }}</span>
54 <strong data-testid="objective-title" class="font-semibold">{{
55 gameStore.currentObjective?.title || "Historical Mission"
56 }}</strong>
57 </span>
58 <span class="ew-hair-c" aria-hidden="true">·</span>
59 <span
60 class="ew-mono font-bold"
61 :class="isVictory ? 'ew-ok' : 'ew-bad'"
62 >
63 <span data-testid="final-progress"
64 >{{ gameStore.objectiveProgress }}%</span
65 >
66 rewritten
67 </span>
68 </p>
69 
70 <RemixCreditLine
71 v-if="remixCredit"
72 :credit="remixCredit"
73 class="mt-2.5"
74 />
75 
76 <p
77 v-if="!isVictory"
78 data-testid="defeat-explanation"
79 class="ew-muted text-sm mt-3 max-w-prose mx-auto"
80 >
81 Your five messages are spent. What you changed remains — but it was
82 not enough, and the objective slipped away.
83 </p>
84 
85 <div
86 v-if="victoryEfficiency"
87 data-testid="efficiency-display"
88 class="mt-3 ew-mono text-sm"
89 >
90 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
91 <span class="ew-muted">
92 · {{ victoryEfficiency.messagesUsed }}/{{
93 TOTAL_MESSAGES
94 }}
95 used<span v-if="victoryEfficiency.messagesSaved > 0">
96 · {{ victoryEfficiency.messagesSaved }} saved</span
97 ></span
98 >
99 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok">
100 · 🌟 to spare</span
101 >
102 </div>
103 </div>
104 
105 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
106 still being written (epiloguePending), we show the "final account…" wait, NOT
107 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
108 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
109 pending and falls back to the prior telling, so the panel never hangs. -->
110 <div
111 v-if="chronicle || epiloguePending"
112 data-testid="end-chronicle"
113 class="mt-6"
114 >
115 <span class="ew-label ew-label--rule"
116 ><span aria-hidden="true">📖</span> The Chronicle</span
117 >
118 <Transition :name="epilogueTransition" mode="out-in">
119 <!-- The final account being WRITTEN — the SAME titled article as the settled
120 epilogue below, parsed from the live stream so it already wears its final
121 shape (full width, titled heading, separated paragraphs); an ink cursor
122 marks the writing tip. Nothing relayouts when the telling lands. Mirrors
123 Chronicle.vue's live view via the shared parser in utils/chronicle.ts. -->
124 <article
125 v-if="epiloguePending && streamingNow"
126 key="epilogue-streaming"
127 data-testid="end-chronicle-streaming"
128 aria-busy="true"
129 >
130 <h4
131 data-testid="end-chronicle-title"
132 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
133 >
134 {{ liveEpilogue.title
135 }}<span
136 v-if="!liveEpilogue.paragraphs.length"
137 class="ink-cursor"
138 aria-hidden="true"
139 />
140 </h4>
141 <p
142 v-for="(para, i) in liveEpilogue.paragraphs"
143 :key="i"
144 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
145 >
146 {{ para
147 }}<span
148 v-if="i === liveEpilogue.paragraphs.length - 1"
149 class="ink-cursor"
150 aria-hidden="true"
151 />
152 </p>
153 </article>
154 <div
155 v-else-if="epiloguePending"
156 key="epilogue-loading"
157 data-testid="end-chronicle-loading"
158 class="ew-faint italic text-sm"
159 >
160 The chronicler sets down the final account…
161 </div>
162 <article
163 v-else-if="chronicle"
164 key="epilogue-body"
165 data-testid="end-chronicle-body"
166 >
167 <h4
168 data-testid="end-chronicle-title"
169 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
170 >
171 {{ chronicle.title }}
172 </h4>
173 <p
174 v-for="(para, i) in chronicle.paragraphs"
175 :key="i"
176 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
177 >
178 {{ para }}
179 </p>
180 </article>
⋯ 442 lines hidden (lines 181–622)
181 </Transition>
182 </div>
183 
184 <!-- The words you sent — the keepsake: your actual messages into the past -->
185 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
186 <span class="ew-label ew-label--rule">The words you sent</span>
187 <ul class="space-y-3.5">
188 <li v-for="(d, i) in dispatches" :key="i">
189 <p class="ew-label">
190 to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span>
191 </p>
192 <blockquote
193 class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1"
194 >
195 "{{ d.text }}"
196 </blockquote>
197 </li>
198 </ul>
199 </div>
200 
201 <!-- Stats — derived from the real resolved turns -->
202 <div data-testid="game-summary" class="mt-7">
203 <span class="ew-label ew-label--rule">How it played out</span>
204 <div class="flex flex-wrap gap-10">
205 <!-- "Peaked at X%" — shown only when the run slipped from its high point
206 (the peak sits above the floored final %). The case it exists for is a
207 staked last dispatch that crit-fails to 0% from a real peak, where the
208 final % alone reads "as if you did nothing" (#129). A clean win never
209 shows it: its peak IS the final 100%. -->
210 <div v-if="showPeak" data-testid="peak-progress">
211 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">
212 {{ gameStore.peakProgress }}%
213 </div>
214 <div class="ew-label mt-1">peaked at</div>
215 </div>
216 <div data-testid="message-efficiency">
217 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">
218 {{ messagesUsed }}
219 </div>
220 <div class="ew-label mt-1">
221 {{ messagesUsed === 1 ? "message" : "messages" }} sent
222 </div>
223 </div>
224 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
225 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">
226 {{ gameSummary.bestRoll.diceRoll }}
227 </div>
228 <div class="ew-label mt-1">
229 {{ messagesUsed >= 2 ? "best roll" : "the roll that did it" }} ·
230 {{ gameSummary.bestRoll.diceOutcome }}
231 </div>
232 </div>
233 <div
234 v-if="gameSummary.worstRoll && messagesUsed >= 2"
235 data-testid="worst-roll"
236 >
237 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">
238 {{ gameSummary.worstRoll.diceRoll }}
239 </div>
240 <div class="ew-label mt-1">
241 worst roll · {{ gameSummary.worstRoll.diceOutcome }}
242 </div>
243 </div>
244 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
245 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
246 It names a variance-driven loss as cold dice, not bad skill. Shown once
247 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
248 that did it" above). The number borrows the same red/green/ink the
249 best/worst stats use; the tooltip carries the below-median detail. -->
250 <div
251 v-if="gameSummary.luck"
252 data-testid="luck-read"
253 :title="luckTitle"
254 >
255 <div
256 class="ew-mono text-3xl font-extrabold leading-none"
257 :class="luckClass"
258 >
259 {{ gameSummary.luck.averageRoll.toFixed(1) }}
260 </div>
261 <div class="ew-label mt-1">
262 avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}
263 </div>
264 </div>
265 </div>
266 </div>
267 
268 <!-- The history you wrote — the Spine itself, exactly as it filled in during
269 the run. Read-only here: no live "▷ now" present marker, no "the timeline
270 trembles…" empty state. Same component the player scrolled and tapped on
271 the board, so the verdict's history reads as richly as it did in play —
272 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
273 <div v-if="timeline.length > 0" class="mt-6">
274 <TimelineLedger readonly />
275 </div>
276 
277 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
278 rewrite? Post it: the public page, the unfurl, the share card. -->
279 <div v-if="canShare" class="mt-8">
280 <ShareControls :run-id="shareRunId" :share-text="shareText" />
281 </div>
282 
283 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
284 <button
285 ref="playAgainRef"
286 type="button"
287 data-testid="play-again-button"
288 class="ew-btn ew-btn--primary"
289 @click="playAgain"
290 >
291 Try a new timeline <span aria-hidden="true"></span>
292 </button>
293 <!-- The finished run just joined your saved-runs history, so the end screen
294 offers an obvious way to reach it: a secondary button beside the primary
295 "Try a new timeline" (the button-styled /runs link mirrors the
296 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
297 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
298 <NuxtLink
299 to="/runs"
300 data-testid="end-your-runs-link"
301 class="ew-btn inline-flex items-center gap-1.5"
302 >
303 <span aria-hidden="true"></span> Your runs
304 </NuxtLink>
305 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
306 <span
307 class="ew-dot"
308 :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'"
309 aria-hidden="true"
310 />
311 this timeline is written.
312 </span>
313 </div>
314 </div>
315 </div>
316</template>
317 
318<script setup lang="ts">
319/**
320 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
321 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
322 * wrote. All stats derive from the real resolved turns.
323 */
324import { ref, computed, watch, nextTick } from "vue";
325import { TOTAL_MESSAGES } from "~/utils/game-config";
326import { useGameStore } from "~/stores/game";
327import { useChronicleStore } from "~/stores/chronicle";
328import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
329import { buildShareText, type RemixCredit } from "~/utils/run-snapshot";
330import { parseLiveChronicle } from "~/utils/chronicle";
331 
332const gameStore = useGameStore();
333const chronicleStore = useChronicleStore();
334 
335// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
336// shown on the end screen the moment the replay resolves. The title is omitted (the
337// objective is already in the masthead above), so it reads "Remixed from <source>".
338const remixCredit = computed<RemixCredit | null>(() =>
339 gameStore.replayState
340 ? {
341 attribution: gameStore.replayState.sourceAttribution,
342 objectiveTitle: "",
343 sourceToken: gameStore.replayState.sourceToken,
344 }
345 : null,
346);
347const reduced = usePrefersReducedMotion();
348 
349const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`;
350 
351// Modal focus management: it's a true takeover (aria-modal), so when it opens we
352// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
353// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
354// the heading — not the bottom Play Again button — also means the screen opens at
355// the top: the verdict and the Chronicle epilogue are the first things seen, not
356// scrolled past below the fold.
357const dialogRef = ref<HTMLElement | null>(null);
358const headingRef = ref<HTMLElement | null>(null);
359const playAgainRef = ref<HTMLElement | null>(null);
360let prevFocus: HTMLElement | null = null;
361 
362watch(
363 () => gameStore.gameStatus,
364 async (s) => {
365 if (typeof document === "undefined") return;
366 if (s === "victory" || s === "defeat") {
367 prevFocus = document.activeElement as HTMLElement | null;
368 await nextTick();
369 if (dialogRef.value) dialogRef.value.scrollTop = 0;
370 headingRef.value?.focus();
371 } else if (prevFocus) {
372 prevFocus.focus?.();
373 prevFocus = null;
374 }
375 },
376);
377 
378function onKeydown(e: KeyboardEvent) {
379 if (e.key !== "Tab" || !dialogRef.value) return;
380 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
381 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
382 );
383 if (!focusables.length) return;
384 const first = focusables[0];
385 const last = focusables[focusables.length - 1];
386 const active = document.activeElement;
387 if (e.shiftKey && active === first) {
388 e.preventDefault();
389 last.focus();
390 } else if (!e.shiftKey && active === last) {
391 e.preventDefault();
392 first.focus();
393 }
395 
396const isVictory = computed(() => gameStore.gameStatus === "victory");
397const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency());
398 
399// The verdict never deflates: spending every message is reframed as "Hard-won," not
400// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
401const victoryPhrase = computed(() => {
402 const rating = victoryEfficiency.value?.efficiencyRating;
403 if (!rating) return "Victory";
404 return rating === "Standard" ? "Hard-won victory" : `${rating} victory`;
405});
406const messagesUsed = computed(
407 () => TOTAL_MESSAGES - gameStore.remainingMessages,
408);
409const gameSummary = computed(() => gameStore.gameSummary);
410 
411// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
412// the win green, an even run the neutral ink — the same at-a-glance colour language
413// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
414// dice," never the player's craft. The tooltip adds the below-median detail the
415// compact label leaves out.
416const luckClass = computed(() => {
417 const v = gameSummary.value.luck?.verdict;
418 return v === "cold" ? "ew-bad" : v === "hot" ? "ew-ok" : "ew-fg";
419});
420const luckPhrase = computed(() => {
421 const v = gameSummary.value.luck?.verdict;
422 return v === "cold"
423 ? "the dice ran cold"
424 : v === "hot"
425 ? "the dice ran hot"
426 : "the dice ran even";
427});
428const luckTitle = computed(() => {
429 const l = gameSummary.value.luck;
430 if (!l) return undefined;
431 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`;
432});
433 
434// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
435// the peak above the final %. The case it answers is a staked last dispatch that
436// crit-fails to 0% from a real peak, where the floored final % alone would read "as
437// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
438const showPeak = computed(
439 () => gameStore.peakProgress > gameStore.objectiveProgress,
440);
441 
442// Share affordance (issue #88): only for a run that's been durably saved (so there's a
443// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
444const canShare = computed(
445 () => gameStore.runSnapshotSaved && !!gameStore.runId,
446);
447const shareRunId = computed(() => gameStore.runId ?? "");
448const shareText = computed(() =>
449 buildShareText(
450 gameStore.currentObjective?.title ?? "",
451 isVictory.value ? "victory" : "defeat",
452 gameStore.objectiveProgress,
453 ),
454);
455const timeline = computed(() => gameStore.timelineEvents);
456const chronicle = computed(() => chronicleStore.chronicle);
457// The epilogue (terminal telling) is still being written — show the wait, never the
458// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
459// under prefers-reduced-motion (the empty name disables the Transition's classes).
460const epiloguePending = computed(() => chronicleStore.epiloguePending);
461const epilogueTransition = computed(() => (reduced.value ? "" : "epilogue"));
462// The epilogue is being written right now — show the live buffer (only when its
463// stream narrates the live ledger, never a superseded prior stream).
464const streamingNow = computed(
465 () =>
466 chronicleStore.chronicleStreaming &&
467 chronicleStore.chronicleStreamLen === gameStore.timelineEvents.length &&
468 chronicleStore.chronicleStream.length > 0,
469);
470// The epilogue mid-stream, parsed into the settled {title, paragraphs} shape so the
471// live account already wears its final form — full width, titled heading, separated
472// paragraphs — and nothing relayouts when the terminal telling lands. Same parser and
473// intent as Chronicle.vue's live view (utils/chronicle.ts).
474const liveEpilogue = computed(() =>
475 parseLiveChronicle(chronicleStore.chronicleStream),
476);
477 
478// The keepsake: the player's verbatim messages, paired with whom/when they reached.
479const dispatches = computed(() => {
480 const users = gameStore.messageHistory.filter((m) => m.sender === "user");
481 const events = gameStore.timelineEvents;
482 return users.map((m, i) => ({
483 text: m.text,
484 figure: m.figureName || events[i]?.figureName || "the past",
485 era: events[i]?.era || "",
486 }));
487});
488 
489// The page owns the FULL reset (store + figure input + composer), so Play Again
490// emits instead of resetting the store directly — the two reset paths used to
491// diverge here, leaving the previous run's figure stranded in the input.
492const emit = defineEmits<{ "play-again": [] }>();
493const playAgain = () => {
494 emit("play-again");
495};
496</script>
497 
498<style scoped>
499/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
500 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
501 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
502 instant swap with no classes attached. */
503.epilogue-enter-active,
504.epilogue-leave-active {
505 transition: opacity var(--ew-dur-3) var(--ew-ease);
507.epilogue-enter-from,
508.epilogue-leave-to {
509 opacity: 0;
511 
512/* The epilogue being written — an ink cursor at the writing tip (the prose itself
513 reuses the settled telling's serif article, so it needs no separate type styling). */
514.ink-cursor {
515 display: inline-block;
516 width: 2px;
517 height: 1.05em;
518 margin-left: 1px;
519 vertical-align: text-bottom;
520 background: var(--ew-accent);
521 animation: ink-blink 1s steps(2, start) infinite;
523@keyframes ink-blink {
524 0%,
525 100% {
526 opacity: 1;
527 }
528 50% {
529 opacity: 0;
530 }
532@media (prefers-reduced-motion: reduce) {
533 .ink-cursor {
534 animation: none;
535 opacity: 0.7;
536 }
538 
539/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
540 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
541.end-screen {
542 background: var(--ew-bg);
543 animation: end-in var(--ew-dur-3) var(--ew-ease) both;
545@keyframes end-in {
546 from {
547 opacity: 0;
548 }
549 to {
550 opacity: 1;
551 }
553@media (prefers-reduced-motion: reduce) {
554 .end-screen {
555 animation: none;
556 }
558.end--win {
559 background:
560 radial-gradient(
561 135% 70% at 50% -12%,
562 color-mix(in srgb, var(--ew-accent) 13%, transparent),
563 transparent 68%
564 ),
565 var(--ew-bg);
567.end--loss {
568 background:
569 radial-gradient(
570 135% 70% at 50% -12%,
571 color-mix(in srgb, var(--ew-bad) 6%, transparent),
572 transparent 60%
573 ),
574 var(--ew-bg);
576 
577/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
578 the loss seal is a cold, flat stamp. */
579.verdict-seal {
580 display: inline-grid;
581 place-items: center;
582 width: 60px;
583 height: 60px;
584 border-radius: 50%;
585 border: 2px solid;
586 font-size: 1.6rem;
587 line-height: 1;
589.verdict-seal.is-win {
590 color: var(--ew-accent);
591 border-color: var(--ew-accent);
592 box-shadow:
593 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
594 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
595 animation: seal-strike 0.6s var(--ew-ease-spring) both;
597.verdict-seal.is-loss {
598 color: var(--ew-faint);
599 border-color: var(--ew-line);
600 box-shadow: inset 0 0 0 4px
601 color-mix(in srgb, var(--ew-faint) 8%, transparent);
603 
604@keyframes seal-strike {
605 0% {
606 opacity: 0;
607 transform: scale(0.5) rotate(-12deg);
608 }
609 60% {
610 transform: scale(1.12) rotate(3deg);
611 }
612 100% {
613 opacity: 1;
614 transform: scale(1) rotate(0);
615 }
617@media (prefers-reduced-motion: reduce) {
618 .verdict-seal.is-win {
619 animation: none;
620 }
622</style>

Parsing the live buffer the same way it settles

liveEpilogue parses the raw stream with parseLiveChronicle into the settled {title, paragraphs} shape the template iterates. It sits right beside streamingNow, the guard that already decided this buffer is the live one (its length matches the current ledger).

streamingNow gates the branch; liveEpilogue gives it the parsed shape.

components/EndGameScreen.vue · 622 lines
components/EndGameScreen.vue622 lines · Vue
⋯ 463 lines hidden (lines 1–463)
1<template>
2 <div
3 v-if="
4 gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'
5 "
6 ref="dialogRef"
7 data-testid="end-game-screen"
8 role="dialog"
9 aria-modal="true"
10 :aria-labelledby="headingId"
11 class="end-screen fixed inset-0 z-50 overflow-y-auto"
12 :class="isVictory ? 'end--win' : 'end--loss'"
13 @keydown="onKeydown"
14 >
15 <div class="max-w-4xl mx-auto px-5 py-8">
16 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
17 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
18 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
19 <div class="text-center border-b ew-line pb-6">
20 <div
21 class="verdict-seal"
22 :class="isVictory ? 'is-win' : 'is-loss'"
23 aria-hidden="true"
24 >
25 {{ isVictory ? "✦" : "⊘" }}
26 </div>
27 <h2
28 v-if="isVictory"
29 :id="headingId"
30 ref="headingRef"
31 tabindex="-1"
32 data-testid="victory-message"
33 class="ew-serif text-3xl sm:text-4xl font-bold ew-fg mt-3 outline-none"
34 >
35 History Rewritten
36 </h2>
37 <h2
38 v-else
39 :id="headingId"
40 ref="headingRef"
41 tabindex="-1"
42 data-testid="defeat-message"
43 class="ew-serif text-3xl sm:text-4xl font-bold ew-muted mt-3 outline-none"
44 >
45 The Timeline Held
46 </h2>
47 <p
48 class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap"
49 >
50 <span class="ew-muted inline-flex items-center gap-1.5">
51 <span aria-hidden="true">{{
52 gameStore.currentObjective?.icon
53 }}</span>
54 <strong data-testid="objective-title" class="font-semibold">{{
55 gameStore.currentObjective?.title || "Historical Mission"
56 }}</strong>
57 </span>
58 <span class="ew-hair-c" aria-hidden="true">·</span>
59 <span
60 class="ew-mono font-bold"
61 :class="isVictory ? 'ew-ok' : 'ew-bad'"
62 >
63 <span data-testid="final-progress"
64 >{{ gameStore.objectiveProgress }}%</span
65 >
66 rewritten
67 </span>
68 </p>
69 
70 <RemixCreditLine
71 v-if="remixCredit"
72 :credit="remixCredit"
73 class="mt-2.5"
74 />
75 
76 <p
77 v-if="!isVictory"
78 data-testid="defeat-explanation"
79 class="ew-muted text-sm mt-3 max-w-prose mx-auto"
80 >
81 Your five messages are spent. What you changed remains — but it was
82 not enough, and the objective slipped away.
83 </p>
84 
85 <div
86 v-if="victoryEfficiency"
87 data-testid="efficiency-display"
88 class="mt-3 ew-mono text-sm"
89 >
90 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
91 <span class="ew-muted">
92 · {{ victoryEfficiency.messagesUsed }}/{{
93 TOTAL_MESSAGES
94 }}
95 used<span v-if="victoryEfficiency.messagesSaved > 0">
96 · {{ victoryEfficiency.messagesSaved }} saved</span
97 ></span
98 >
99 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok">
100 · 🌟 to spare</span
101 >
102 </div>
103 </div>
104 
105 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
106 still being written (epiloguePending), we show the "final account…" wait, NOT
107 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
108 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
109 pending and falls back to the prior telling, so the panel never hangs. -->
110 <div
111 v-if="chronicle || epiloguePending"
112 data-testid="end-chronicle"
113 class="mt-6"
114 >
115 <span class="ew-label ew-label--rule"
116 ><span aria-hidden="true">📖</span> The Chronicle</span
117 >
118 <Transition :name="epilogueTransition" mode="out-in">
119 <!-- The final account being WRITTEN — the SAME titled article as the settled
120 epilogue below, parsed from the live stream so it already wears its final
121 shape (full width, titled heading, separated paragraphs); an ink cursor
122 marks the writing tip. Nothing relayouts when the telling lands. Mirrors
123 Chronicle.vue's live view via the shared parser in utils/chronicle.ts. -->
124 <article
125 v-if="epiloguePending && streamingNow"
126 key="epilogue-streaming"
127 data-testid="end-chronicle-streaming"
128 aria-busy="true"
129 >
130 <h4
131 data-testid="end-chronicle-title"
132 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
133 >
134 {{ liveEpilogue.title
135 }}<span
136 v-if="!liveEpilogue.paragraphs.length"
137 class="ink-cursor"
138 aria-hidden="true"
139 />
140 </h4>
141 <p
142 v-for="(para, i) in liveEpilogue.paragraphs"
143 :key="i"
144 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
145 >
146 {{ para
147 }}<span
148 v-if="i === liveEpilogue.paragraphs.length - 1"
149 class="ink-cursor"
150 aria-hidden="true"
151 />
152 </p>
153 </article>
154 <div
155 v-else-if="epiloguePending"
156 key="epilogue-loading"
157 data-testid="end-chronicle-loading"
158 class="ew-faint italic text-sm"
159 >
160 The chronicler sets down the final account…
161 </div>
162 <article
163 v-else-if="chronicle"
164 key="epilogue-body"
165 data-testid="end-chronicle-body"
166 >
167 <h4
168 data-testid="end-chronicle-title"
169 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
170 >
171 {{ chronicle.title }}
172 </h4>
173 <p
174 v-for="(para, i) in chronicle.paragraphs"
175 :key="i"
176 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
177 >
178 {{ para }}
179 </p>
180 </article>
181 </Transition>
182 </div>
183 
184 <!-- The words you sent — the keepsake: your actual messages into the past -->
185 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
186 <span class="ew-label ew-label--rule">The words you sent</span>
187 <ul class="space-y-3.5">
188 <li v-for="(d, i) in dispatches" :key="i">
189 <p class="ew-label">
190 to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span>
191 </p>
192 <blockquote
193 class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1"
194 >
195 "{{ d.text }}"
196 </blockquote>
197 </li>
198 </ul>
199 </div>
200 
201 <!-- Stats — derived from the real resolved turns -->
202 <div data-testid="game-summary" class="mt-7">
203 <span class="ew-label ew-label--rule">How it played out</span>
204 <div class="flex flex-wrap gap-10">
205 <!-- "Peaked at X%" — shown only when the run slipped from its high point
206 (the peak sits above the floored final %). The case it exists for is a
207 staked last dispatch that crit-fails to 0% from a real peak, where the
208 final % alone reads "as if you did nothing" (#129). A clean win never
209 shows it: its peak IS the final 100%. -->
210 <div v-if="showPeak" data-testid="peak-progress">
211 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">
212 {{ gameStore.peakProgress }}%
213 </div>
214 <div class="ew-label mt-1">peaked at</div>
215 </div>
216 <div data-testid="message-efficiency">
217 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">
218 {{ messagesUsed }}
219 </div>
220 <div class="ew-label mt-1">
221 {{ messagesUsed === 1 ? "message" : "messages" }} sent
222 </div>
223 </div>
224 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
225 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">
226 {{ gameSummary.bestRoll.diceRoll }}
227 </div>
228 <div class="ew-label mt-1">
229 {{ messagesUsed >= 2 ? "best roll" : "the roll that did it" }} ·
230 {{ gameSummary.bestRoll.diceOutcome }}
231 </div>
232 </div>
233 <div
234 v-if="gameSummary.worstRoll && messagesUsed >= 2"
235 data-testid="worst-roll"
236 >
237 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">
238 {{ gameSummary.worstRoll.diceRoll }}
239 </div>
240 <div class="ew-label mt-1">
241 worst roll · {{ gameSummary.worstRoll.diceOutcome }}
242 </div>
243 </div>
244 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
245 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
246 It names a variance-driven loss as cold dice, not bad skill. Shown once
247 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
248 that did it" above). The number borrows the same red/green/ink the
249 best/worst stats use; the tooltip carries the below-median detail. -->
250 <div
251 v-if="gameSummary.luck"
252 data-testid="luck-read"
253 :title="luckTitle"
254 >
255 <div
256 class="ew-mono text-3xl font-extrabold leading-none"
257 :class="luckClass"
258 >
259 {{ gameSummary.luck.averageRoll.toFixed(1) }}
260 </div>
261 <div class="ew-label mt-1">
262 avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}
263 </div>
264 </div>
265 </div>
266 </div>
267 
268 <!-- The history you wrote — the Spine itself, exactly as it filled in during
269 the run. Read-only here: no live "▷ now" present marker, no "the timeline
270 trembles…" empty state. Same component the player scrolled and tapped on
271 the board, so the verdict's history reads as richly as it did in play —
272 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
273 <div v-if="timeline.length > 0" class="mt-6">
274 <TimelineLedger readonly />
275 </div>
276 
277 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
278 rewrite? Post it: the public page, the unfurl, the share card. -->
279 <div v-if="canShare" class="mt-8">
280 <ShareControls :run-id="shareRunId" :share-text="shareText" />
281 </div>
282 
283 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
284 <button
285 ref="playAgainRef"
286 type="button"
287 data-testid="play-again-button"
288 class="ew-btn ew-btn--primary"
289 @click="playAgain"
290 >
291 Try a new timeline <span aria-hidden="true"></span>
292 </button>
293 <!-- The finished run just joined your saved-runs history, so the end screen
294 offers an obvious way to reach it: a secondary button beside the primary
295 "Try a new timeline" (the button-styled /runs link mirrors the
296 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
297 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
298 <NuxtLink
299 to="/runs"
300 data-testid="end-your-runs-link"
301 class="ew-btn inline-flex items-center gap-1.5"
302 >
303 <span aria-hidden="true"></span> Your runs
304 </NuxtLink>
305 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
306 <span
307 class="ew-dot"
308 :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'"
309 aria-hidden="true"
310 />
311 this timeline is written.
312 </span>
313 </div>
314 </div>
315 </div>
316</template>
317 
318<script setup lang="ts">
319/**
320 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
321 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
322 * wrote. All stats derive from the real resolved turns.
323 */
324import { ref, computed, watch, nextTick } from "vue";
325import { TOTAL_MESSAGES } from "~/utils/game-config";
326import { useGameStore } from "~/stores/game";
327import { useChronicleStore } from "~/stores/chronicle";
328import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
329import { buildShareText, type RemixCredit } from "~/utils/run-snapshot";
330import { parseLiveChronicle } from "~/utils/chronicle";
331 
332const gameStore = useGameStore();
333const chronicleStore = useChronicleStore();
334 
335// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
336// shown on the end screen the moment the replay resolves. The title is omitted (the
337// objective is already in the masthead above), so it reads "Remixed from <source>".
338const remixCredit = computed<RemixCredit | null>(() =>
339 gameStore.replayState
340 ? {
341 attribution: gameStore.replayState.sourceAttribution,
342 objectiveTitle: "",
343 sourceToken: gameStore.replayState.sourceToken,
344 }
345 : null,
346);
347const reduced = usePrefersReducedMotion();
348 
349const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`;
350 
351// Modal focus management: it's a true takeover (aria-modal), so when it opens we
352// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
353// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
354// the heading — not the bottom Play Again button — also means the screen opens at
355// the top: the verdict and the Chronicle epilogue are the first things seen, not
356// scrolled past below the fold.
357const dialogRef = ref<HTMLElement | null>(null);
358const headingRef = ref<HTMLElement | null>(null);
359const playAgainRef = ref<HTMLElement | null>(null);
360let prevFocus: HTMLElement | null = null;
361 
362watch(
363 () => gameStore.gameStatus,
364 async (s) => {
365 if (typeof document === "undefined") return;
366 if (s === "victory" || s === "defeat") {
367 prevFocus = document.activeElement as HTMLElement | null;
368 await nextTick();
369 if (dialogRef.value) dialogRef.value.scrollTop = 0;
370 headingRef.value?.focus();
371 } else if (prevFocus) {
372 prevFocus.focus?.();
373 prevFocus = null;
374 }
375 },
376);
377 
378function onKeydown(e: KeyboardEvent) {
379 if (e.key !== "Tab" || !dialogRef.value) return;
380 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
381 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
382 );
383 if (!focusables.length) return;
384 const first = focusables[0];
385 const last = focusables[focusables.length - 1];
386 const active = document.activeElement;
387 if (e.shiftKey && active === first) {
388 e.preventDefault();
389 last.focus();
390 } else if (!e.shiftKey && active === last) {
391 e.preventDefault();
392 first.focus();
393 }
395 
396const isVictory = computed(() => gameStore.gameStatus === "victory");
397const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency());
398 
399// The verdict never deflates: spending every message is reframed as "Hard-won," not
400// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
401const victoryPhrase = computed(() => {
402 const rating = victoryEfficiency.value?.efficiencyRating;
403 if (!rating) return "Victory";
404 return rating === "Standard" ? "Hard-won victory" : `${rating} victory`;
405});
406const messagesUsed = computed(
407 () => TOTAL_MESSAGES - gameStore.remainingMessages,
408);
409const gameSummary = computed(() => gameStore.gameSummary);
410 
411// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
412// the win green, an even run the neutral ink — the same at-a-glance colour language
413// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
414// dice," never the player's craft. The tooltip adds the below-median detail the
415// compact label leaves out.
416const luckClass = computed(() => {
417 const v = gameSummary.value.luck?.verdict;
418 return v === "cold" ? "ew-bad" : v === "hot" ? "ew-ok" : "ew-fg";
419});
420const luckPhrase = computed(() => {
421 const v = gameSummary.value.luck?.verdict;
422 return v === "cold"
423 ? "the dice ran cold"
424 : v === "hot"
425 ? "the dice ran hot"
426 : "the dice ran even";
427});
428const luckTitle = computed(() => {
429 const l = gameSummary.value.luck;
430 if (!l) return undefined;
431 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`;
432});
433 
434// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
435// the peak above the final %. The case it answers is a staked last dispatch that
436// crit-fails to 0% from a real peak, where the floored final % alone would read "as
437// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
438const showPeak = computed(
439 () => gameStore.peakProgress > gameStore.objectiveProgress,
440);
441 
442// Share affordance (issue #88): only for a run that's been durably saved (so there's a
443// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
444const canShare = computed(
445 () => gameStore.runSnapshotSaved && !!gameStore.runId,
446);
447const shareRunId = computed(() => gameStore.runId ?? "");
448const shareText = computed(() =>
449 buildShareText(
450 gameStore.currentObjective?.title ?? "",
451 isVictory.value ? "victory" : "defeat",
452 gameStore.objectiveProgress,
453 ),
454);
455const timeline = computed(() => gameStore.timelineEvents);
456const chronicle = computed(() => chronicleStore.chronicle);
457// The epilogue (terminal telling) is still being written — show the wait, never the
458// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
459// under prefers-reduced-motion (the empty name disables the Transition's classes).
460const epiloguePending = computed(() => chronicleStore.epiloguePending);
461const epilogueTransition = computed(() => (reduced.value ? "" : "epilogue"));
462// The epilogue is being written right now — show the live buffer (only when its
463// stream narrates the live ledger, never a superseded prior stream).
464const streamingNow = computed(
465 () =>
466 chronicleStore.chronicleStreaming &&
467 chronicleStore.chronicleStreamLen === gameStore.timelineEvents.length &&
468 chronicleStore.chronicleStream.length > 0,
469);
470// The epilogue mid-stream, parsed into the settled {title, paragraphs} shape so the
471// live account already wears its final form — full width, titled heading, separated
472// paragraphs — and nothing relayouts when the terminal telling lands. Same parser and
473// intent as Chronicle.vue's live view (utils/chronicle.ts).
474const liveEpilogue = computed(() =>
475 parseLiveChronicle(chronicleStore.chronicleStream),
476);
⋯ 146 lines hidden (lines 477–622)
477 
478// The keepsake: the player's verbatim messages, paired with whom/when they reached.
479const dispatches = computed(() => {
480 const users = gameStore.messageHistory.filter((m) => m.sender === "user");
481 const events = gameStore.timelineEvents;
482 return users.map((m, i) => ({
483 text: m.text,
484 figure: m.figureName || events[i]?.figureName || "the past",
485 era: events[i]?.era || "",
486 }));
487});
488 
489// The page owns the FULL reset (store + figure input + composer), so Play Again
490// emits instead of resetting the store directly — the two reset paths used to
491// diverge here, leaving the previous run's figure stranded in the input.
492const emit = defineEmits<{ "play-again": [] }>();
493const playAgain = () => {
494 emit("play-again");
495};
496</script>
497 
498<style scoped>
499/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
500 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
501 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
502 instant swap with no classes attached. */
503.epilogue-enter-active,
504.epilogue-leave-active {
505 transition: opacity var(--ew-dur-3) var(--ew-ease);
507.epilogue-enter-from,
508.epilogue-leave-to {
509 opacity: 0;
511 
512/* The epilogue being written — an ink cursor at the writing tip (the prose itself
513 reuses the settled telling's serif article, so it needs no separate type styling). */
514.ink-cursor {
515 display: inline-block;
516 width: 2px;
517 height: 1.05em;
518 margin-left: 1px;
519 vertical-align: text-bottom;
520 background: var(--ew-accent);
521 animation: ink-blink 1s steps(2, start) infinite;
523@keyframes ink-blink {
524 0%,
525 100% {
526 opacity: 1;
527 }
528 50% {
529 opacity: 0;
530 }
532@media (prefers-reduced-motion: reduce) {
533 .ink-cursor {
534 animation: none;
535 opacity: 0.7;
536 }
538 
539/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
540 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
541.end-screen {
542 background: var(--ew-bg);
543 animation: end-in var(--ew-dur-3) var(--ew-ease) both;
545@keyframes end-in {
546 from {
547 opacity: 0;
548 }
549 to {
550 opacity: 1;
551 }
553@media (prefers-reduced-motion: reduce) {
554 .end-screen {
555 animation: none;
556 }
558.end--win {
559 background:
560 radial-gradient(
561 135% 70% at 50% -12%,
562 color-mix(in srgb, var(--ew-accent) 13%, transparent),
563 transparent 68%
564 ),
565 var(--ew-bg);
567.end--loss {
568 background:
569 radial-gradient(
570 135% 70% at 50% -12%,
571 color-mix(in srgb, var(--ew-bad) 6%, transparent),
572 transparent 60%
573 ),
574 var(--ew-bg);
576 
577/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
578 the loss seal is a cold, flat stamp. */
579.verdict-seal {
580 display: inline-grid;
581 place-items: center;
582 width: 60px;
583 height: 60px;
584 border-radius: 50%;
585 border: 2px solid;
586 font-size: 1.6rem;
587 line-height: 1;
589.verdict-seal.is-win {
590 color: var(--ew-accent);
591 border-color: var(--ew-accent);
592 box-shadow:
593 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
594 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
595 animation: seal-strike 0.6s var(--ew-ease-spring) both;
597.verdict-seal.is-loss {
598 color: var(--ew-faint);
599 border-color: var(--ew-line);
600 box-shadow: inset 0 0 0 4px
601 color-mix(in srgb, var(--ew-faint) 8%, transparent);
603 
604@keyframes seal-strike {
605 0% {
606 opacity: 0;
607 transform: scale(0.5) rotate(-12deg);
608 }
609 60% {
610 transform: scale(1.12) rotate(3deg);
611 }
612 100% {
613 opacity: 1;
614 transform: scale(1) rotate(0);
615 }
617@media (prefers-reduced-motion: reduce) {
618 .verdict-seal.is-win {
619 animation: none;
620 }
622</style>

The one new import — the shared client-safe parser.

components/EndGameScreen.vue · 622 lines
components/EndGameScreen.vue622 lines · Vue
⋯ 329 lines hidden (lines 1–329)
1<template>
2 <div
3 v-if="
4 gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'
5 "
6 ref="dialogRef"
7 data-testid="end-game-screen"
8 role="dialog"
9 aria-modal="true"
10 :aria-labelledby="headingId"
11 class="end-screen fixed inset-0 z-50 overflow-y-auto"
12 :class="isVictory ? 'end--win' : 'end--loss'"
13 @keydown="onKeydown"
14 >
15 <div class="max-w-4xl mx-auto px-5 py-8">
16 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
17 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
18 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
19 <div class="text-center border-b ew-line pb-6">
20 <div
21 class="verdict-seal"
22 :class="isVictory ? 'is-win' : 'is-loss'"
23 aria-hidden="true"
24 >
25 {{ isVictory ? "✦" : "⊘" }}
26 </div>
27 <h2
28 v-if="isVictory"
29 :id="headingId"
30 ref="headingRef"
31 tabindex="-1"
32 data-testid="victory-message"
33 class="ew-serif text-3xl sm:text-4xl font-bold ew-fg mt-3 outline-none"
34 >
35 History Rewritten
36 </h2>
37 <h2
38 v-else
39 :id="headingId"
40 ref="headingRef"
41 tabindex="-1"
42 data-testid="defeat-message"
43 class="ew-serif text-3xl sm:text-4xl font-bold ew-muted mt-3 outline-none"
44 >
45 The Timeline Held
46 </h2>
47 <p
48 class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap"
49 >
50 <span class="ew-muted inline-flex items-center gap-1.5">
51 <span aria-hidden="true">{{
52 gameStore.currentObjective?.icon
53 }}</span>
54 <strong data-testid="objective-title" class="font-semibold">{{
55 gameStore.currentObjective?.title || "Historical Mission"
56 }}</strong>
57 </span>
58 <span class="ew-hair-c" aria-hidden="true">·</span>
59 <span
60 class="ew-mono font-bold"
61 :class="isVictory ? 'ew-ok' : 'ew-bad'"
62 >
63 <span data-testid="final-progress"
64 >{{ gameStore.objectiveProgress }}%</span
65 >
66 rewritten
67 </span>
68 </p>
69 
70 <RemixCreditLine
71 v-if="remixCredit"
72 :credit="remixCredit"
73 class="mt-2.5"
74 />
75 
76 <p
77 v-if="!isVictory"
78 data-testid="defeat-explanation"
79 class="ew-muted text-sm mt-3 max-w-prose mx-auto"
80 >
81 Your five messages are spent. What you changed remains — but it was
82 not enough, and the objective slipped away.
83 </p>
84 
85 <div
86 v-if="victoryEfficiency"
87 data-testid="efficiency-display"
88 class="mt-3 ew-mono text-sm"
89 >
90 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
91 <span class="ew-muted">
92 · {{ victoryEfficiency.messagesUsed }}/{{
93 TOTAL_MESSAGES
94 }}
95 used<span v-if="victoryEfficiency.messagesSaved > 0">
96 · {{ victoryEfficiency.messagesSaved }} saved</span
97 ></span
98 >
99 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok">
100 · 🌟 to spare</span
101 >
102 </div>
103 </div>
104 
105 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
106 still being written (epiloguePending), we show the "final account…" wait, NOT
107 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
108 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
109 pending and falls back to the prior telling, so the panel never hangs. -->
110 <div
111 v-if="chronicle || epiloguePending"
112 data-testid="end-chronicle"
113 class="mt-6"
114 >
115 <span class="ew-label ew-label--rule"
116 ><span aria-hidden="true">📖</span> The Chronicle</span
117 >
118 <Transition :name="epilogueTransition" mode="out-in">
119 <!-- The final account being WRITTEN — the SAME titled article as the settled
120 epilogue below, parsed from the live stream so it already wears its final
121 shape (full width, titled heading, separated paragraphs); an ink cursor
122 marks the writing tip. Nothing relayouts when the telling lands. Mirrors
123 Chronicle.vue's live view via the shared parser in utils/chronicle.ts. -->
124 <article
125 v-if="epiloguePending && streamingNow"
126 key="epilogue-streaming"
127 data-testid="end-chronicle-streaming"
128 aria-busy="true"
129 >
130 <h4
131 data-testid="end-chronicle-title"
132 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
133 >
134 {{ liveEpilogue.title
135 }}<span
136 v-if="!liveEpilogue.paragraphs.length"
137 class="ink-cursor"
138 aria-hidden="true"
139 />
140 </h4>
141 <p
142 v-for="(para, i) in liveEpilogue.paragraphs"
143 :key="i"
144 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
145 >
146 {{ para
147 }}<span
148 v-if="i === liveEpilogue.paragraphs.length - 1"
149 class="ink-cursor"
150 aria-hidden="true"
151 />
152 </p>
153 </article>
154 <div
155 v-else-if="epiloguePending"
156 key="epilogue-loading"
157 data-testid="end-chronicle-loading"
158 class="ew-faint italic text-sm"
159 >
160 The chronicler sets down the final account…
161 </div>
162 <article
163 v-else-if="chronicle"
164 key="epilogue-body"
165 data-testid="end-chronicle-body"
166 >
167 <h4
168 data-testid="end-chronicle-title"
169 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
170 >
171 {{ chronicle.title }}
172 </h4>
173 <p
174 v-for="(para, i) in chronicle.paragraphs"
175 :key="i"
176 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
177 >
178 {{ para }}
179 </p>
180 </article>
181 </Transition>
182 </div>
183 
184 <!-- The words you sent — the keepsake: your actual messages into the past -->
185 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
186 <span class="ew-label ew-label--rule">The words you sent</span>
187 <ul class="space-y-3.5">
188 <li v-for="(d, i) in dispatches" :key="i">
189 <p class="ew-label">
190 to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span>
191 </p>
192 <blockquote
193 class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1"
194 >
195 "{{ d.text }}"
196 </blockquote>
197 </li>
198 </ul>
199 </div>
200 
201 <!-- Stats — derived from the real resolved turns -->
202 <div data-testid="game-summary" class="mt-7">
203 <span class="ew-label ew-label--rule">How it played out</span>
204 <div class="flex flex-wrap gap-10">
205 <!-- "Peaked at X%" — shown only when the run slipped from its high point
206 (the peak sits above the floored final %). The case it exists for is a
207 staked last dispatch that crit-fails to 0% from a real peak, where the
208 final % alone reads "as if you did nothing" (#129). A clean win never
209 shows it: its peak IS the final 100%. -->
210 <div v-if="showPeak" data-testid="peak-progress">
211 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">
212 {{ gameStore.peakProgress }}%
213 </div>
214 <div class="ew-label mt-1">peaked at</div>
215 </div>
216 <div data-testid="message-efficiency">
217 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">
218 {{ messagesUsed }}
219 </div>
220 <div class="ew-label mt-1">
221 {{ messagesUsed === 1 ? "message" : "messages" }} sent
222 </div>
223 </div>
224 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
225 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">
226 {{ gameSummary.bestRoll.diceRoll }}
227 </div>
228 <div class="ew-label mt-1">
229 {{ messagesUsed >= 2 ? "best roll" : "the roll that did it" }} ·
230 {{ gameSummary.bestRoll.diceOutcome }}
231 </div>
232 </div>
233 <div
234 v-if="gameSummary.worstRoll && messagesUsed >= 2"
235 data-testid="worst-roll"
236 >
237 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">
238 {{ gameSummary.worstRoll.diceRoll }}
239 </div>
240 <div class="ew-label mt-1">
241 worst roll · {{ gameSummary.worstRoll.diceOutcome }}
242 </div>
243 </div>
244 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
245 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
246 It names a variance-driven loss as cold dice, not bad skill. Shown once
247 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
248 that did it" above). The number borrows the same red/green/ink the
249 best/worst stats use; the tooltip carries the below-median detail. -->
250 <div
251 v-if="gameSummary.luck"
252 data-testid="luck-read"
253 :title="luckTitle"
254 >
255 <div
256 class="ew-mono text-3xl font-extrabold leading-none"
257 :class="luckClass"
258 >
259 {{ gameSummary.luck.averageRoll.toFixed(1) }}
260 </div>
261 <div class="ew-label mt-1">
262 avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}
263 </div>
264 </div>
265 </div>
266 </div>
267 
268 <!-- The history you wrote — the Spine itself, exactly as it filled in during
269 the run. Read-only here: no live "▷ now" present marker, no "the timeline
270 trembles…" empty state. Same component the player scrolled and tapped on
271 the board, so the verdict's history reads as richly as it did in play —
272 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
273 <div v-if="timeline.length > 0" class="mt-6">
274 <TimelineLedger readonly />
275 </div>
276 
277 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
278 rewrite? Post it: the public page, the unfurl, the share card. -->
279 <div v-if="canShare" class="mt-8">
280 <ShareControls :run-id="shareRunId" :share-text="shareText" />
281 </div>
282 
283 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
284 <button
285 ref="playAgainRef"
286 type="button"
287 data-testid="play-again-button"
288 class="ew-btn ew-btn--primary"
289 @click="playAgain"
290 >
291 Try a new timeline <span aria-hidden="true"></span>
292 </button>
293 <!-- The finished run just joined your saved-runs history, so the end screen
294 offers an obvious way to reach it: a secondary button beside the primary
295 "Try a new timeline" (the button-styled /runs link mirrors the
296 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
297 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
298 <NuxtLink
299 to="/runs"
300 data-testid="end-your-runs-link"
301 class="ew-btn inline-flex items-center gap-1.5"
302 >
303 <span aria-hidden="true"></span> Your runs
304 </NuxtLink>
305 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
306 <span
307 class="ew-dot"
308 :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'"
309 aria-hidden="true"
310 />
311 this timeline is written.
312 </span>
313 </div>
314 </div>
315 </div>
316</template>
317 
318<script setup lang="ts">
319/**
320 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
321 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
322 * wrote. All stats derive from the real resolved turns.
323 */
324import { ref, computed, watch, nextTick } from "vue";
325import { TOTAL_MESSAGES } from "~/utils/game-config";
326import { useGameStore } from "~/stores/game";
327import { useChronicleStore } from "~/stores/chronicle";
328import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
329import { buildShareText, type RemixCredit } from "~/utils/run-snapshot";
330import { parseLiveChronicle } from "~/utils/chronicle";
⋯ 292 lines hidden (lines 331–622)
331 
332const gameStore = useGameStore();
333const chronicleStore = useChronicleStore();
334 
335// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
336// shown on the end screen the moment the replay resolves. The title is omitted (the
337// objective is already in the masthead above), so it reads "Remixed from <source>".
338const remixCredit = computed<RemixCredit | null>(() =>
339 gameStore.replayState
340 ? {
341 attribution: gameStore.replayState.sourceAttribution,
342 objectiveTitle: "",
343 sourceToken: gameStore.replayState.sourceToken,
344 }
345 : null,
346);
347const reduced = usePrefersReducedMotion();
348 
349const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`;
350 
351// Modal focus management: it's a true takeover (aria-modal), so when it opens we
352// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
353// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
354// the heading — not the bottom Play Again button — also means the screen opens at
355// the top: the verdict and the Chronicle epilogue are the first things seen, not
356// scrolled past below the fold.
357const dialogRef = ref<HTMLElement | null>(null);
358const headingRef = ref<HTMLElement | null>(null);
359const playAgainRef = ref<HTMLElement | null>(null);
360let prevFocus: HTMLElement | null = null;
361 
362watch(
363 () => gameStore.gameStatus,
364 async (s) => {
365 if (typeof document === "undefined") return;
366 if (s === "victory" || s === "defeat") {
367 prevFocus = document.activeElement as HTMLElement | null;
368 await nextTick();
369 if (dialogRef.value) dialogRef.value.scrollTop = 0;
370 headingRef.value?.focus();
371 } else if (prevFocus) {
372 prevFocus.focus?.();
373 prevFocus = null;
374 }
375 },
376);
377 
378function onKeydown(e: KeyboardEvent) {
379 if (e.key !== "Tab" || !dialogRef.value) return;
380 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
381 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
382 );
383 if (!focusables.length) return;
384 const first = focusables[0];
385 const last = focusables[focusables.length - 1];
386 const active = document.activeElement;
387 if (e.shiftKey && active === first) {
388 e.preventDefault();
389 last.focus();
390 } else if (!e.shiftKey && active === last) {
391 e.preventDefault();
392 first.focus();
393 }
395 
396const isVictory = computed(() => gameStore.gameStatus === "victory");
397const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency());
398 
399// The verdict never deflates: spending every message is reframed as "Hard-won," not
400// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
401const victoryPhrase = computed(() => {
402 const rating = victoryEfficiency.value?.efficiencyRating;
403 if (!rating) return "Victory";
404 return rating === "Standard" ? "Hard-won victory" : `${rating} victory`;
405});
406const messagesUsed = computed(
407 () => TOTAL_MESSAGES - gameStore.remainingMessages,
408);
409const gameSummary = computed(() => gameStore.gameSummary);
410 
411// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
412// the win green, an even run the neutral ink — the same at-a-glance colour language
413// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
414// dice," never the player's craft. The tooltip adds the below-median detail the
415// compact label leaves out.
416const luckClass = computed(() => {
417 const v = gameSummary.value.luck?.verdict;
418 return v === "cold" ? "ew-bad" : v === "hot" ? "ew-ok" : "ew-fg";
419});
420const luckPhrase = computed(() => {
421 const v = gameSummary.value.luck?.verdict;
422 return v === "cold"
423 ? "the dice ran cold"
424 : v === "hot"
425 ? "the dice ran hot"
426 : "the dice ran even";
427});
428const luckTitle = computed(() => {
429 const l = gameSummary.value.luck;
430 if (!l) return undefined;
431 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`;
432});
433 
434// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
435// the peak above the final %. The case it answers is a staked last dispatch that
436// crit-fails to 0% from a real peak, where the floored final % alone would read "as
437// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
438const showPeak = computed(
439 () => gameStore.peakProgress > gameStore.objectiveProgress,
440);
441 
442// Share affordance (issue #88): only for a run that's been durably saved (so there's a
443// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
444const canShare = computed(
445 () => gameStore.runSnapshotSaved && !!gameStore.runId,
446);
447const shareRunId = computed(() => gameStore.runId ?? "");
448const shareText = computed(() =>
449 buildShareText(
450 gameStore.currentObjective?.title ?? "",
451 isVictory.value ? "victory" : "defeat",
452 gameStore.objectiveProgress,
453 ),
454);
455const timeline = computed(() => gameStore.timelineEvents);
456const chronicle = computed(() => chronicleStore.chronicle);
457// The epilogue (terminal telling) is still being written — show the wait, never the
458// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
459// under prefers-reduced-motion (the empty name disables the Transition's classes).
460const epiloguePending = computed(() => chronicleStore.epiloguePending);
461const epilogueTransition = computed(() => (reduced.value ? "" : "epilogue"));
462// The epilogue is being written right now — show the live buffer (only when its
463// stream narrates the live ledger, never a superseded prior stream).
464const streamingNow = computed(
465 () =>
466 chronicleStore.chronicleStreaming &&
467 chronicleStore.chronicleStreamLen === gameStore.timelineEvents.length &&
468 chronicleStore.chronicleStream.length > 0,
469);
470// The epilogue mid-stream, parsed into the settled {title, paragraphs} shape so the
471// live account already wears its final form — full width, titled heading, separated
472// paragraphs — and nothing relayouts when the terminal telling lands. Same parser and
473// intent as Chronicle.vue's live view (utils/chronicle.ts).
474const liveEpilogue = computed(() =>
475 parseLiveChronicle(chronicleStore.chronicleStream),
476);
477 
478// The keepsake: the player's verbatim messages, paired with whom/when they reached.
479const dispatches = computed(() => {
480 const users = gameStore.messageHistory.filter((m) => m.sender === "user");
481 const events = gameStore.timelineEvents;
482 return users.map((m, i) => ({
483 text: m.text,
484 figure: m.figureName || events[i]?.figureName || "the past",
485 era: events[i]?.era || "",
486 }));
487});
488 
489// The page owns the FULL reset (store + figure input + composer), so Play Again
490// emits instead of resetting the store directly — the two reset paths used to
491// diverge here, leaving the previous run's figure stranded in the input.
492const emit = defineEmits<{ "play-again": [] }>();
493const playAgain = () => {
494 emit("play-again");
495};
496</script>
497 
498<style scoped>
499/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
500 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
501 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
502 instant swap with no classes attached. */
503.epilogue-enter-active,
504.epilogue-leave-active {
505 transition: opacity var(--ew-dur-3) var(--ew-ease);
507.epilogue-enter-from,
508.epilogue-leave-to {
509 opacity: 0;
511 
512/* The epilogue being written — an ink cursor at the writing tip (the prose itself
513 reuses the settled telling's serif article, so it needs no separate type styling). */
514.ink-cursor {
515 display: inline-block;
516 width: 2px;
517 height: 1.05em;
518 margin-left: 1px;
519 vertical-align: text-bottom;
520 background: var(--ew-accent);
521 animation: ink-blink 1s steps(2, start) infinite;
523@keyframes ink-blink {
524 0%,
525 100% {
526 opacity: 1;
527 }
528 50% {
529 opacity: 0;
530 }
532@media (prefers-reduced-motion: reduce) {
533 .ink-cursor {
534 animation: none;
535 opacity: 0.7;
536 }
538 
539/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
540 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
541.end-screen {
542 background: var(--ew-bg);
543 animation: end-in var(--ew-dur-3) var(--ew-ease) both;
545@keyframes end-in {
546 from {
547 opacity: 0;
548 }
549 to {
550 opacity: 1;
551 }
553@media (prefers-reduced-motion: reduce) {
554 .end-screen {
555 animation: none;
556 }
558.end--win {
559 background:
560 radial-gradient(
561 135% 70% at 50% -12%,
562 color-mix(in srgb, var(--ew-accent) 13%, transparent),
563 transparent 68%
564 ),
565 var(--ew-bg);
567.end--loss {
568 background:
569 radial-gradient(
570 135% 70% at 50% -12%,
571 color-mix(in srgb, var(--ew-bad) 6%, transparent),
572 transparent 60%
573 ),
574 var(--ew-bg);
576 
577/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
578 the loss seal is a cold, flat stamp. */
579.verdict-seal {
580 display: inline-grid;
581 place-items: center;
582 width: 60px;
583 height: 60px;
584 border-radius: 50%;
585 border: 2px solid;
586 font-size: 1.6rem;
587 line-height: 1;
589.verdict-seal.is-win {
590 color: var(--ew-accent);
591 border-color: var(--ew-accent);
592 box-shadow:
593 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
594 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
595 animation: seal-strike 0.6s var(--ew-ease-spring) both;
597.verdict-seal.is-loss {
598 color: var(--ew-faint);
599 border-color: var(--ew-line);
600 box-shadow: inset 0 0 0 4px
601 color-mix(in srgb, var(--ew-faint) 8%, transparent);
603 
604@keyframes seal-strike {
605 0% {
606 opacity: 0;
607 transform: scale(0.5) rotate(-12deg);
608 }
609 60% {
610 transform: scale(1.12) rotate(3deg);
611 }
612 100% {
613 opacity: 1;
614 transform: scale(1) rotate(0);
615 }
617@media (prefers-reduced-motion: reduce) {
618 .verdict-seal.is-win {
619 animation: none;
620 }
622</style>

The test, and how it was verified

A new case drives the streaming state and asserts the fixed shape: a titled heading, its text present, the body split into its own paragraph elements, and the old narrow .end-chronicle-live view absent. It fails on the pre-fix markup (which had no title element and one blob paragraph), so it discriminates the fix.

Streaming now renders the settled shape — titled heading, separated paragraphs, no raw blob.

tests/unit/components/EndGameScreen.spec.ts · 453 lines
tests/unit/components/EndGameScreen.spec.ts453 lines · TypeScript
⋯ 153 lines hidden (lines 1–153)
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 { useChronicleStore } from "../../../stores/chronicle";
7import { DiceOutcome } from "../../../utils/dice";
8import type { GameObjective } from "../../../server/utils/objectives";
9import type { Message } from "../../../stores/game";
10 
11const OBJ: GameObjective = {
12 title: "Reach the Moon by 1900",
13 description: "Leave Earth half a century early.",
14 era: "19th century",
15 icon: "🚀",
16};
17 
18function resolvedTurn(
19 diceRoll: number,
20 diceOutcome: DiceOutcome,
21 progressChange: number,
22 naturalRoll?: number,
23): Message {
24 return {
25 text: "reply",
26 sender: "ai",
27 figureName: "Tesla",
28 timestamp: new Date(),
29 diceRoll,
30 diceOutcome,
31 progressChange,
32 naturalRoll,
33 timelineImpact: "history shifts",
34 };
36function prompt(): Message {
37 return {
38 text: "prompt",
39 sender: "user",
40 figureName: "Tesla",
41 timestamp: new Date(),
42 };
44 
45// NuxtLink can't resolve RouterLink under a bare @vue/test-utils mount, so stub it
46// per-mount to a plain anchor (href ← `to`): the footer "Your runs" link is the only
47// NuxtLink here, so this keeps every mount free of the resolve warning and lets the
48// link's text/href be asserted directly. (The real render is covered by the e2e.)
49const mountEnd = () =>
50 mount(EndGameScreen, {
51 global: {
52 stubs: {
53 NuxtLink: { props: ["to"], template: '<a :href="to"><slot /></a>' },
54 },
55 },
56 });
57 
58describe("EndGameScreen", () => {
59 let gameStore: ReturnType<typeof useGameStore>;
60 let chronicleStore: ReturnType<typeof useChronicleStore>;
61 
62 beforeEach(() => {
63 setActivePinia(createPinia());
64 gameStore = useGameStore();
65 chronicleStore = useChronicleStore();
66 });
67 
68 describe("Victory", () => {
69 beforeEach(() => {
70 gameStore.gameStatus = "victory";
71 gameStore.objectiveProgress = 100;
72 gameStore.currentObjective = OBJ;
73 gameStore.remainingMessages = 2;
74 });
75 
76 it("shows a victory headline", () => {
77 const v = mountEnd().find('[data-testid="victory-message"]');
78 expect(v.exists()).toBe(true);
79 expect(v.text()).toContain("History Rewritten");
80 });
81 
82 it("shows the objective and final progress", () => {
83 const w = mountEnd();
84 expect(w.find('[data-testid="objective-title"]').text()).toContain(
85 "Reach the Moon by 1900",
86 );
87 expect(w.find('[data-testid="final-progress"]').text()).toContain("100%");
88 });
89 
90 it("shows efficiency with messages saved", () => {
91 const e = mountEnd().find('[data-testid="efficiency-display"]');
92 expect(e.exists()).toBe(true);
93 expect(e.text()).toContain("saved");
94 });
95 });
96 
97 describe("Defeat", () => {
98 beforeEach(() => {
99 gameStore.gameStatus = "defeat";
100 gameStore.objectiveProgress = 45;
101 gameStore.currentObjective = OBJ;
102 gameStore.remainingMessages = 0;
103 });
104 
105 it("shows a defeat headline", () => {
106 const d = mountEnd().find('[data-testid="defeat-message"]');
107 expect(d.exists()).toBe(true);
108 expect(d.text()).toContain("Timeline Held");
109 });
110 
111 it("shows final progress below 100%", () => {
112 expect(
113 mountEnd().find('[data-testid="final-progress"]').text(),
114 ).toContain("45%");
115 });
116 
117 it("explains the loss", () => {
118 expect(
119 mountEnd().find('[data-testid="defeat-explanation"]').exists(),
120 ).toBe(true);
121 });
122 });
123 
124 // The epilogue is the run's payoff — the terminal telling that carries the verdict.
125 // While it's still being written we must never present the prior turn's stale,
126 // pre-ending telling as the epilogue (issue #107).
127 describe("The Chronicle epilogue — pending, reveal, and fallback", () => {
128 const stale = {
129 title: "Mid-run Telling",
130 paragraphs: ["the world as it stood a turn ago"],
131 };
132 const epilogue = {
133 title: "The World Remade",
134 paragraphs: ["and so the timeline was rewritten"],
135 };
136 
137 beforeEach(() => {
138 gameStore.gameStatus = "victory";
139 gameStore.currentObjective = OBJ;
140 });
141 
142 it('shows the "final account" wait while the epilogue is pending — not the stale prior telling', () => {
143 chronicleStore.chronicle = stale; // a stale, pre-ending telling is still held
144 chronicleStore.epiloguePending = true; // the terminal refresh is in flight
145 const w = mount(EndGameScreen);
146 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(
147 true,
148 );
149 // The prior-turn telling must NOT be presented as the epilogue.
150 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(false);
151 expect(w.text()).not.toContain("Mid-run Telling");
152 });
153 
154 it("streams the epilogue in the settled shape — a titled heading and separated paragraphs, not a raw half-width blob", () => {
155 // While the terminal telling is still being written, the live buffer must render
156 // in the SAME shape as the settled epilogue (parsed title + paragraphs) so it reads
157 // at full width in prose type and nothing relayouts when it lands — the old raw
158 // pre-wrap view was capped at ~half width. Mirrors Chronicle.vue's live view.
159 chronicleStore.epiloguePending = true;
160 chronicleStore.chronicleStreaming = true;
161 // streamLen === timelineEvents.length (both 0 here) marks this as the live stream.
162 chronicleStore.chronicleStreamLen = 0;
163 chronicleStore.chronicleStream =
164 "The World Remade\n\nAnd so the timeline was rewritten.\n\nThe age turned on it.";
165 const w = mount(EndGameScreen);
166 const streaming = w.find('[data-testid="end-chronicle-streaming"]');
167 expect(streaming.exists()).toBe(true);
168 expect(
169 streaming.find('[data-testid="end-chronicle-title"]').text(),
170 ).toContain("The World Remade");
171 // The body is split into its own paragraph elements, not one pre-wrapped blob.
172 expect(streaming.findAll("p").length).toBe(2);
173 // The old narrow (max-width: 64ch) raw-stream view is gone.
174 expect(streaming.find(".end-chronicle-live").exists()).toBe(false);
175 });
⋯ 278 lines hidden (lines 176–453)
176 
177 it("reveals the epilogue once the terminal telling lands", () => {
178 chronicleStore.chronicle = epilogue;
179 chronicleStore.epiloguePending = false;
180 const w = mount(EndGameScreen);
181 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(
182 false,
183 );
184 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true);
185 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain(
186 "The World Remade",
187 );
188 });
189 
190 it("falls back to the prior telling if the final refresh failed — never stuck on loading", () => {
191 // refreshChronicle keeps the prior telling on failure and clears pending.
192 chronicleStore.chronicle = stale;
193 chronicleStore.epiloguePending = false;
194 const w = mount(EndGameScreen);
195 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(
196 false,
197 );
198 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true);
199 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain(
200 "Mid-run Telling",
201 );
202 });
203 });
204 
205 describe("Summary stats — derived from resolved AI turns", () => {
206 beforeEach(() => {
207 gameStore.gameStatus = "victory";
208 gameStore.remainingMessages = 3; // 2 used
209 gameStore.messageHistory = [
210 prompt(),
211 resolvedTurn(18, DiceOutcome.SUCCESS, 25),
212 prompt(),
213 resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75),
214 ];
215 });
216 
217 it("renders the summary block", () => {
218 expect(mountEnd().find('[data-testid="game-summary"]').exists()).toBe(
219 true,
220 );
221 });
222 
223 it("highlights the best roll from the AI turns", () => {
224 const best = mountEnd().find('[data-testid="best-roll"]');
225 expect(best.exists()).toBe(true);
226 expect(best.text()).toContain("20");
227 });
228 
229 it("highlights the worst roll from the AI turns", () => {
230 gameStore.messageHistory[1] = resolvedTurn(
231 2,
232 DiceOutcome.CRITICAL_FAILURE,
233 -10,
234 );
235 const worst = mountEnd().find('[data-testid="worst-roll"]');
236 expect(worst.exists()).toBe(true);
237 expect(worst.text()).toContain("2");
238 });
239 
240 it("shows the number of messages sent", () => {
241 const eff = mountEnd().find('[data-testid="message-efficiency"]');
242 expect(eff.exists()).toBe(true);
243 expect(eff.text()).toContain("2");
244 });
245 });
246 
247 // The run-level luck read (#165): names a variance-driven loss as cold dice, not
248 // bad skill. It reads the natural d20s before the craft tilt; with no naturalRoll on
249 // these fixtures it falls back to the effective roll (mirrors the real reveal).
250 describe("Luck read (#165) — the dice, not the craft", () => {
251 beforeEach(() => {
252 gameStore.gameStatus = "defeat";
253 gameStore.objectiveProgress = 40;
254 gameStore.remainingMessages = 3; // 2 used
255 });
256 
257 it("reads cold dice when the run rolled below the mean, in the loss red", () => {
258 gameStore.messageHistory = [
259 prompt(),
260 resolvedTurn(4, DiceOutcome.FAILURE, -5),
261 prompt(),
262 resolvedTurn(6, DiceOutcome.FAILURE, -3),
263 ];
264 const tile = mountEnd().find('[data-testid="luck-read"]');
265 expect(tile.exists()).toBe(true);
266 expect(tile.find(".ew-mono").text()).toBe("5.0"); // (4 + 6) / 2, shown to one decimal
267 expect(tile.text()).toContain("avg vs 10.5");
268 expect(tile.text()).toContain("the dice ran cold");
269 expect(tile.find(".ew-mono").classes()).toContain("ew-bad");
270 });
271 
272 it("reads the natural die at the render boundary, not the craft-tilted effective roll", () => {
273 // Effective rolls (9, 11) would average 10 → even; the natural dice were cold.
274 gameStore.messageHistory = [
275 prompt(),
276 resolvedTurn(9, DiceOutcome.NEUTRAL, 8, 4),
277 prompt(),
278 resolvedTurn(11, DiceOutcome.NEUTRAL, 8, 6),
279 ];
280 const tile = mountEnd().find('[data-testid="luck-read"]');
281 expect(tile.find(".ew-mono").text()).toBe("5.0"); // (4 + 6) / 2, not (9 + 11) / 2 = 10
282 expect(tile.text()).toContain("the dice ran cold");
283 });
284 
285 it("reads hot dice when the run rolled above the mean, in the win green", () => {
286 gameStore.messageHistory = [
287 prompt(),
288 resolvedTurn(15, DiceOutcome.SUCCESS, 25),
289 prompt(),
290 resolvedTurn(18, DiceOutcome.SUCCESS, 30),
291 ];
292 const tile = mountEnd().find('[data-testid="luck-read"]');
293 expect(tile.text()).toContain("16.5");
294 expect(tile.text()).toContain("the dice ran hot");
295 expect(tile.find(".ew-mono").classes()).toContain("ew-ok");
296 });
297 
298 it('stays hidden on a single roll — no "overall" read to give', () => {
299 gameStore.messageHistory = [
300 prompt(),
301 resolvedTurn(15, DiceOutcome.SUCCESS, 20),
302 ];
303 expect(mountEnd().find('[data-testid="luck-read"]').exists()).toBe(false);
304 });
305 });
306 
307 // A run that built progress then lost ground — classically a staked final dispatch
308 // that crit-failed to 0% — must not read "as if the player did nothing." The summary
309 // surfaces "peaked at X%" whenever the peak sits above the floored final % (#129).
310 describe("Peaked at X% — when a run loses ground", () => {
311 it("shows the peak in the summary when the run slipped from its high point", () => {
312 gameStore.gameStatus = "defeat";
313 gameStore.objectiveProgress = 0; // a staked last dispatch crit-failed to nothing
314 gameStore.peakProgress = 25; // but the run had really reached 25%
315 const peak = mountEnd().find('[data-testid="peak-progress"]');
316 expect(peak.exists()).toBe(true);
317 expect(peak.text()).toContain("25%");
318 });
319 
320 it("stays hidden on a clean win, where the peak is the final 100%", () => {
321 gameStore.gameStatus = "victory";
322 gameStore.objectiveProgress = 100;
323 gameStore.peakProgress = 100;
324 expect(mountEnd().find('[data-testid="peak-progress"]').exists()).toBe(
325 false,
326 );
327 });
328 });
329 
330 // The "history you wrote" renders through the shared Spine (TimelineLedger),
331 // not a bespoke list — so the verdict's history reads exactly like the in-game
332 // timeline (valence dots, badges, the Δ equation), per issue #84.
333 describe("The history you wrote — the shared Spine", () => {
334 beforeEach(() => {
335 gameStore.gameStatus = "victory";
336 gameStore.addTimelineEvent({
337 figureName: "Tesla",
338 era: "1890s",
339 headline: "The grid hums to life",
340 detail: "Alternating current spreads.",
341 diceRoll: 18,
342 diceOutcome: DiceOutcome.SUCCESS,
343 progressChange: 25,
344 });
345 });
346 
347 it("renders via the shared TimelineLedger, not the old hand-rolled list", () => {
348 const w = mountEnd();
349 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true);
350 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true);
351 // The bespoke block folded into the Spine's testid.
352 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false);
353 });
354 
355 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
356 expect(mountEnd().find(".spine-now").exists()).toBe(false);
357 });
358 });
359 
360 // The Chronicle epilogue fills the full debrief column like its siblings (the
361 // dispatches / stats / timeline). It used to carry an inner max-w-[62ch] measure
362 // that read as ~2/3 width next to those full-width sections (#109).
363 describe("Chronicle epilogue", () => {
364 beforeEach(() => {
365 gameStore.gameStatus = "victory";
366 chronicleStore.chronicle = {
367 title: "The Library Stands",
368 paragraphs: ["It did not burn."],
369 };
370 });
371 
372 it("renders the epilogue body", () => {
373 const body = mount(EndGameScreen).find(
374 '[data-testid="end-chronicle-body"]',
375 );
376 expect(body.exists()).toBe(true);
377 expect(body.text()).toContain("It did not burn.");
378 });
379 
380 it("fills the column — no max-w-[62ch] cap", () => {
381 const body = mount(EndGameScreen).find(
382 '[data-testid="end-chronicle-body"]',
383 );
384 expect(body.classes()).not.toContain("max-w-[62ch]");
385 });
386 });
387 
388 describe("Play again", () => {
389 beforeEach(() => {
390 gameStore.gameStatus = "victory";
391 });
392 
393 it("offers a restart", () => {
394 const btn = mountEnd().find('[data-testid="play-again-button"]');
395 expect(btn.exists()).toBe(true);
396 expect(btn.text()).toContain("new timeline");
397 });
398 
399 it("asks the page for the unified reset when clicked (it never resets the store itself)", async () => {
400 const wrapper = mountEnd();
401 await wrapper.find('[data-testid="play-again-button"]').trigger("click");
402 
403 // The page owns the FULL reset (store + figure input + composer); the
404 // old direct store reset left the previous run's figure in the input.
405 expect(wrapper.emitted("play-again")).toHaveLength(1);
406 expect(gameStore.gameStatus).toBe("victory"); // untouched by the component
407 });
408 });
409 
410 // The finished run becomes part of the player's history, so the end screen
411 // surfaces the same "Your runs" affordance the retrospective side uses.
412 describe("Your runs link", () => {
413 beforeEach(() => {
414 gameStore.gameStatus = "victory";
415 });
416 
417 it("offers an obvious /runs link beside Play Again", () => {
418 const link = mountEnd().find('[data-testid="end-your-runs-link"]');
419 expect(link.exists()).toBe(true);
420 expect(link.text()).toContain("Your runs");
421 expect(link.attributes("href")).toContain("/runs");
422 });
423 
424 it("shows it on a loss too", () => {
425 gameStore.gameStatus = "defeat";
426 expect(
427 mountEnd().find('[data-testid="end-your-runs-link"]').exists(),
428 ).toBe(true);
429 });
430 });
431 
432 describe("Edge cases", () => {
433 it("handles a missing objective", () => {
434 gameStore.gameStatus = "victory";
435 gameStore.currentObjective = null;
436 expect(() => mountEnd()).not.toThrow();
437 });
438 
439 it("does not render while still playing", () => {
440 gameStore.gameStatus = "playing";
441 expect(mountEnd().find('[data-testid="end-game-screen"]').exists()).toBe(
442 false,
443 );
444 });
445 
446 it("exposes dialog semantics", () => {
447 gameStore.gameStatus = "victory";
448 const screen = mountEnd().find('[data-testid="end-game-screen"]');
449 expect(screen.attributes("role")).toBe("dialog");
450 expect(screen.attributes("aria-labelledby")).toBeTruthy();
451 });
452 });
453});