What changed, and why

The play screen's top bar (the HUD) carried a small status pill: a colored dot next to a word. While you played it read Active; when a run ended it read Victory or Defeat. This change removes it.

The word earned its place in only one of those states, and barely. During play it always said "Active", which tells the player nothing they don't already know. The two end states already get a full-screen takeover (the EndGameScreen, pinned fixed inset-0), so the bar's copy was redundant there too. What's left is an 11-line deletion in one file, pages/play.vue: the pill's markup and the two computeds that fed it.

What came out

The pill lived in the run-controls cluster that appears once an objective is live, sitting between the messages counter and the new-timeline (↻) button.

statusDot painted the dot; statusWord was the label.

Removed — the status pill (from the HUD header) · 4 lines
Removed — the status pill (from the HUD header)4 lines · HTML
1<span class="flex items-center gap-1.5 pl-1">
2 <span class="rv-dot" :class="statusDot" aria-hidden="true" />
3 <span class="rv-mono text-[11px] uppercase tracking-wide rv-muted sr-only sm:not-sr-only">{{ statusWord }}</span>
4</span>

Two computeds fed it, mapping the game's status to a dot color and that word:

Removed — statusDot and statusWord · 6 lines
Removed — statusDot and statusWord6 lines · TypeScript
1const statusDot = computed(() =>
2 gameStore.gameStatus === 'victory' ? 'rv-dot--ok' : gameStore.gameStatus === 'defeat' ? 'rv-dot--bad' : 'rv-dot--accent'
3)
4const statusWord = computed(() =>
5 gameStore.gameStatus === 'victory' ? 'Victory' : gameStore.gameStatus === 'defeat' ? 'Defeat' : 'Active'
6)

The header now, and why it's safe

After the cut, the cluster holds just the messages counter and the reset control. The parent flex row's gap handles spacing, so removing the middle child shifts nothing else.

The HUD header at HEAD. The run-controls cluster (line 16) now goes straight from the counter to the reset control.

pages/play.vue · 567 lines
pages/play.vue567 lines · HTML
⋯ 3 lines hidden (lines 1–3)
1<template>
2 <div class="min-h-screen lg:h-screen rv-bg flex flex-col lg:overflow-hidden">
3 <!-- HUD: wordmark · objective mission-strip · gauge · status · dark · new-timeline -->
4 <header class="border-b rv-line">
5 <!-- On phones the bar wraps: controls stay on the top line, and the objective
6 (with its foldable brief) drops to its own full-width line so the brief
7 reads at full width instead of a thin column. Single row from sm up. -->
8 <div class="px-3 sm:px-5 py-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:flex-nowrap sm:gap-4">
9 <GameTitle />
10 
11 <div v-if="gameStore.currentObjective" class="order-last basis-full min-w-0 sm:order-none sm:basis-auto sm:flex-1">
12 <ObjectiveDisplay data-testid="objective-display" />
13 </div>
14 <div v-else class="flex-1" />
15 
16 <div v-if="gameStore.currentObjective" class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto sm:ml-0">
17 <MessagesCounter data-testid="messages-counter" />
18 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
19 progress the reset arms a tiny inline confirm (auto-reverts); an
20 untouched run still resets in one tap. -->
21 <span v-if="resetArmed" class="flex items-center gap-1.5">
22 <span class="text-[11px] rv-warn">abandon this history?</span>
23 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
24 aria-label="Yes — abandon this history" @click="performReset"></button>
25 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
26 aria-label="Keep playing" @click="disarmReset"></button>
27 </span>
28 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
29 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
30 </div>
31 
32 <!-- Runs gauge — always visible (briefing + board), and the entry to the
33 account popover / the run-packs modal. -->
34 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
35 
36 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
37 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
38 </div>
39 </header>
⋯ 528 lines hidden (lines 40–567)
40 
41 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
42 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
43 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
44 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
45 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
46 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
47 <span class="rv-fg">
48 <template v-if="gameStore.purchaseNotice === 'success'">
49 Payment received — your runs have been added to your balance.
50 </template>
51 <template v-else>Checkout canceled — you weren't charged.</template>
52 </span>
53 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
54 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
55 </div>
56 
57 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
58 board reads as "history is being rewritten," in concert with the shaking die. -->
59 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
60 <span class="sr-only">Resolving your move — history is being rewritten…</span>
61 </div>
62 
63 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
64 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
65 on a short viewport that clipping hid the Begin button under the footer — give
66 the briefing a scrollable main instead. -->
67 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
68 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
69 <!-- Briefing: choose (or compose) an objective before the run begins -->
70 <MissionSelect v-if="!gameStore.currentObjective" />
71 
72 <template v-else>
73 <!-- The board, only while the run is live — once it ends, the end-screen
74 takeover fully replaces it (no board bleeding under the verdict).
75 On phones the three zones become one-at-a-time panels driven by the
76 bottom tab bar; from md up every zone is shown together as before. -->
77 <template v-if="gameStore.gameStatus === 'playing'">
78 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
79 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
80 left, and the act-model (the compose dock) pinned on the right, so the move
81 is never buried below the read. Below lg this wrapper is inert and the zones
82 fall back to the stacked page (md) / phone tab panels (sm). -->
83 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
84 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
85 pane: the board/Spine on top at its natural height, then the story zone
86 flexes to fill the rest, so the Chronicle & Thread are full-height panels
87 (each scrolls inside itself) rather than a single scrolling stack. -->
88 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
89 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
90 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
91 <div class="grid sm:grid-cols-2 gap-4">
92 <div class="sm:border-r rv-line sm:pr-6 py-1">
93 <span class="rv-label mb-2 block">Dice of fate</span>
94 <DiceRoller />
95 <!-- The bands, disclosed: the player can always see how fate maps to
96 swing — the same table the Timeline Engine is instructed with. -->
97 <details data-testid="roll-legend" class="mt-2 text-[11px]">
98 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
99 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
100 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
101 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
102 </li>
103 </ul>
104 <p class="rv-faint mt-1.5 leading-snug">✒ craft tilts the roll (±2) · ⚡ anachronism widens the result — gains gently, losses hard · ⚑ a staked final dispatch doubles everything</p>
105 </details>
106 </div>
107 <div class="sm:pl-2 flex flex-col justify-center">
108 <ProgressTracker />
109 </div>
110 </div>
111 <TimelineLedger />
112 </section>
113 
114 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
115 this flexes to fill the rest of the pane and lays the two out side by side
116 as equal full-height columns (md keeps the two-up grid in the page flow). -->
117 <section class="rv-panel mt-9 lg:mt-10 gap-x-8 gap-y-2" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'story' ? 'grid' : 'hidden', 'md:grid md:grid-cols-2', 'lg:grid-rows-1 lg:flex-1 lg:min-h-0']">
118 <Chronicle :force-open="isMd" />
119 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
120 column height), a collapsible <details> on phones. -->
121 <component :is="isMd ? 'div' : 'details'" class="rv-rail lg:h-full lg:min-h-0 lg:flex lg:flex-col" :open="isMd ? null : threadOpen" @toggle="onThreadToggle">
122 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
123 <span class="rv-label">Thread<span class="normal-case tracking-normal rv-faint font-normal"> · your messages<span v-if="gameStore.activeFigureName"> · {{ gameStore.activeFigureName }}</span></span></span>
124 </summary>
125 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
126 <MessageHistory data-testid="message-history" />
127 </div>
128 </component>
129 </section>
130 </div><!-- /LEFT pane -->
131 
132 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
133 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
134 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
135 lg, where it's a column of its own, not a section below the read). -->
136 <section class="rv-panel mt-9 border-t rv-line pt-5 lg:mt-0 lg:border-t-0 lg:pt-0" :class="[mobileTab === 'compose' ? '' : 'hidden', 'md:block']">
137 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
138 <span class="rv-label">Send a message into the past</span>
139 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
140 known-since and a contact year is chosen, the chip translates the
141 reach into the same ⚡ tiers the spine uses. Otherwise the general
142 principle is stated — on the first turn too, where it teaches most. -->
143 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
144 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
145 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
146 </span>
147 <span v-else data-testid="wager-line" class="text-[11px] rv-warn"><span aria-hidden="true"></span> the further your words reach beyond their era, the harder the timeline swings — both ways</span>
148 <!-- The chain, read BEFORE the roll: how far this moment lands from your
149 nearest foothold (the objective's era or a change you've made). A
150 lone leap into the deep past is diffuse; building a chain forward
151 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
152 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
153 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
154 <span aria-hidden="true"></span> {{ chainRead.text }}
155 </span>
156 </div>
157 
158 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
159 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
160 </p>
161 
162 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
163 <div>
164 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
165 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
166 </div>
167 
168 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
169 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
170 <MessageInput ref="messageInputRef" />
171 <!-- The last stand: offered only when the final dispatch can no
172 longer win inside the normal per-turn cap — a true last resort,
173 never a free doubling from a winnable position. -->
174 <div v-if="gameStore.canStake" data-testid="stake-control"
175 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
176 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
177 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
178 :style="{ accentColor: 'var(--rv-accent)' }" />
179 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
180 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
181 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
182 </label>
183 </div>
184 <div class="flex items-center gap-3 flex-wrap">
185 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
186 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
187 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
188 <span class="rv-label shrink-0">notice</span>
189 <span class="rv-muted truncate">{{ gameStore.error }}</span>
190 <button type="button" data-testid="error-close" class="rv-tap rv-hover-fg shrink-0 ml-auto" aria-label="Dismiss notice" @click="gameStore.setError(null)"></button>
191 </div>
192 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
193 class="flex items-start gap-2 text-xs flex-1 min-w-0 border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
194 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
195 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
196 <button type="button" data-testid="moderation-close" class="rv-tap shrink-0 ml-auto hover:text-red-100" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
197 </div>
198 </div>
199 <ArchiveLookup />
200 </div>
201 </div>
202 </section>
203 </div><!-- /RIGHT pane -->
204 </div><!-- /workspace -->
205 </template>
206 </template>
207 </main>
208 
209 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
210 board never becomes one infinite scroll and your next move is always one tap
211 away. Hidden from md up (where every zone is shown at once). -->
212 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
213 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
214 The active view is conveyed with aria-current, the honest, complete pattern. -->
215 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
216 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
217 aria-label="Switch board view">
218 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
219 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
220 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
221 <span class="rv-label flex items-center gap-1">
222 {{ t.label }}
223 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
224 </span>
225 </button>
226 </nav>
227 
228 <!-- A ledger running-foot: header + footer frame the page as a document.
229 (Hidden on phones, where the tab bar is the foot.) -->
230 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
231 <div class="flex items-center gap-2">
232 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
233 <span class="rv-hair-c" aria-hidden="true">·</span>
234 <span class="rv-mono">a history you are writing</span>
235 <span class="ml-auto rv-serif italic">the past is not fixed</span>
236 </div>
237 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
238 <p data-testid="footer-disclosure" class="mt-1.5">
239 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
240 </p>
241 </footer>
242 
243 <EndGameScreen @play-again="performReset" />
244 
245 <!-- The run-packs store: opened from the header/account or automatically when a
246 commit is refused for being out of runs. Mounted once, here. -->
247 <RunPacksModal />
248 
249 <!-- The guided first run (issue #60): a skippable, non-blocking coach-mark that
250 teaches a brand-new player the core loop in context, then gets out of the
251 way. Client-only — it reads localStorage and positions against live rects. -->
252 <ClientOnly><CoachMark /></ClientOnly>
253 </div>
254</template>
255 
256<script setup lang="ts">
257/**
258 * Main game page — Revisionist, "The Spine Console" layout.
259 *
260 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
261 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
262 * by space, not chrome. State lives in the store; this only arranges it.
263 */
264import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
265import { useGameStore, formatContactYear } from '~/stores/game'
266import { useCoachingStore } from '~/stores/coaching'
267import { SWING_BANDS } from '~/utils/swing-bands'
268import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
269import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
270import { CHAIN_HINT } from '~/utils/causal-chain'
271import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
272import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
273 
274const gameStore = useGameStore()
275 
276// The disclosed band table — rendered from the same constants the Timeline
277// Engine is instructed with, so the legend can't lie.
278const fmtSwing = (b: { min: number; max: number }) =>
279 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
280const rollLegend = [
281 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
282 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
283 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
284 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
285 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
287 
288// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
289const wagerRead = computed(() => {
290 const lookup = gameStore.archiveResult
291 const when = gameStore.contactWhen
292 if (!lookup?.knownSince || when == null) return null
293 const knownYear = parseKnownSinceYear(lookup.knownSince)
294 if (knownYear == null) return null
295 const tier = wagerTier(knownYear, when)
296 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
297 // Hedged copy: this is a year-gap compass, not the engine's verdict.
298 const text = tier === 'in-period'
299 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
300 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
301 return { tier, pips, text }
302})
303 
304// The pre-send chain chip: how far the chosen moment lands from the nearest
305// foothold (the objective's era or a prior change). Hidden at the hinge (full
306// force, nothing to flag); otherwise it reads the dilution + how to close it.
307const chainRead = computed(() => {
308 const s = gameStore.causalChainRead
309 if (!s || s.tier === 'at-hinge') return null
310 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
311})
312 
313// The last stand, armed by the player on their final dispatch.
314const stakeArmed = ref(false)
315 
316const figure = ref('')
317watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
318 
319const messageInputRef = ref()
320const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
321const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
322 
323// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
324const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
325 
326// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
327// this state is inert there). The run opens on the move; the instant a turn is rolling
328// we flip to the Board so the die + shift + spine land as the payoff beat, then the
329// player taps back to Compose. A nudge dot marks Compose when it's their move.
330type MobileTab = 'board' | 'story' | 'compose'
331const mobileTabs = [
332 { id: 'board', glyph: '🎲', label: 'Board' },
333 { id: 'story', glyph: '📖', label: 'Story' },
334 { id: 'compose', glyph: '✎', label: 'Compose' }
335] as const
336const mobileTab = ref<MobileTab>('compose')
337const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
338watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
339watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
340 
341// Thread rail (below md): open by default (the figure's replies are the game's most
342// charming content — collapsed-by-default made them too easy to miss). A fold is the
343// player's explicit choice now, so nothing force-reopens it. From md up it's forced
344// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
345// guard the handler so the native toggle event can't flip our own state.
346const threadOpen = ref(true)
347function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
348 
349// When a run begins, drop the cursor straight into the "who" field.
350function focusFigure() {
351 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
353watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
354 
355// Lock body scroll while the end-screen takeover is up (so the board can't peek).
356watch(() => gameStore.gameStatus, (s) => {
357 if (typeof document === 'undefined') return
358 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
359})
360 
361// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
362// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
363// a collapsible rail in the Story tab); tracked reactively.
364const isMd = ref(false)
365let mdMql: MediaQueryList | null = null
366function syncMd() { isMd.value = mdMql?.matches ?? false }
367onMounted(() => {
368 mdMql = window.matchMedia('(min-width: 768px)')
369 syncMd()
370 mdMql.addEventListener('change', syncMd)
371})
372onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
373 
374// Load the device's run balance for the header gauge, and handle the return from
375// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
376// then the query is stripped so a later refresh can't re-fire it.
377const route = useRoute()
378const router = useRouter()
379const supabase = useSupabaseClient()
380onMounted(async () => {
381 // A magic-link / email-change sign-in lands back here carrying an artifact to
382 // consume (?code= the Supabase client auto-exchanges; ?token_hash= we verify;
383 // ?error= means the link failed). On success the anonymous account upgrades in
384 // place — same id, runs carried over — so re-read the balance (the account view
385 // is server-derived) and strip the query so a reload can't re-fire a spent link.
386 const authReturn = readAuthReturnParams(route.query)
387 if (hasAuthReturn(authReturn)) {
388 const result = await finalizeAuthReturn(authReturn, {
389 settle: async () => {
390 const { data } = await supabase.auth.getSession()
391 return Boolean(data.session && !data.session.user.is_anonymous)
392 },
393 verifyOtp: (params) => supabase.auth.verifyOtp(params)
394 })
395 if (result.status === 'error') {
396 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
397 }
398 await gameStore.loadBalance()
399 router.replace({ query: {} })
400 return
401 }
402 
403 const purchase = route.query.purchase
404 if (purchase === 'success' || purchase === 'cancel') {
405 await gameStore.notePurchaseReturn(purchase)
406 router.replace({ query: {} })
407 // The webhook credits asynchronously and can lag Stripe's redirect, so the
408 // first read may predate the credit. Poll briefly so the header gauge
409 // converges to the credited total without a manual reload — stopping as soon
410 // as it changes, or after a few tries (the credit is guaranteed by the
411 // webhook + its retries regardless). The banner asserts no specific count.
412 if (purchase === 'success') {
413 const before = gameStore.runsRemaining
414 for (let i = 0; i < 4; i++) {
415 await new Promise((r) => setTimeout(r, 1500))
416 await gameStore.loadBalance()
417 if (gameStore.runsRemaining !== before) break
418 }
419 }
420 } else {
421 await gameStore.loadBalance()
422 }
423})
424 
425// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
426// reloads, respects the OS preference, and ships a no-flash inline script — replacing
427// the old manual classList toggle that did none of those.
428const colorMode = useColorMode()
429const isDark = computed(() => colorMode.value === 'dark')
430function toggleDark() {
431 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
433 
434const handleSendMessage = async () => {
435 if (!messageInputRef.value) return
436 const messageText = messageInputRef.value.message?.trim()
437 const target = figure.value.trim()
438 if (!messageText || !messageInputRef.value.isValid || !target) return
439 
440 // Clear the composer only when the turn actually resolved — a blocked or
441 // failed send keeps the player's crafted 160 characters for another try.
442 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
443 if (resolved) {
444 stakeArmed.value = false
445 if (messageInputRef.value) messageInputRef.value.message = ''
446 }
448 
449// The header reset arms a confirm when a run is in progress (and auto-disarms);
450// performReset is the single unified reset path — the end screen's Play Again
451// lands here too, so every reset clears the figure input and composer alike.
452const resetArmed = ref(false)
453let resetArmTimer: ReturnType<typeof setTimeout> | null = null
454 
455const handleResetGame = () => {
456 // One tap only when there is truly nothing to lose: no resolved turns AND no
457 // words or contact in progress (a typed first dispatch is work too).
458 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
459 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
460 performReset()
461 return
462 }
463 resetArmed.value = true
464 if (resetArmTimer) clearTimeout(resetArmTimer)
465 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
467 
468function disarmReset() {
469 resetArmed.value = false
470 if (resetArmTimer) clearTimeout(resetArmTimer)
472 
473function performReset() {
474 disarmReset()
475 stakeArmed.value = false
476 gameStore.resetGame()
477 figure.value = ''
478 if (messageInputRef.value) messageInputRef.value.message = ''
479 focusFigure()
481 
482onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
483 
484// ── Guided first run (issue #60) ──────────────────────────────────────────
485// A teaching layer for a brand-new player's first game. This page is the single
486// orchestration site: it reads the game and drives the coaching store one way,
487// so the store stays game-agnostic and survives resetGame() untouched. The board
488// never blocks — the coach-mark only points; the player plays through it.
489const coaching = useCoachingStore()
490const autoStartCtx = () => ({
491 hasObjective: !!gameStore.currentObjective,
492 isPlaying: gameStore.gameStatus === 'playing',
493 isFirstTurn: gameStore.timelineEvents.length === 0
494})
495onMounted(() => {
496 coaching.hydrate()
497 coaching.maybeAutoStart(autoStartCtx())
498})
499// A run beginning cues a brand-new player's first beat; tearing the board down
500// (New timeline) ends a tour left running rather than stranding it over an empty board.
501watch(() => gameStore.currentObjective, (obj) => {
502 if (obj) coaching.maybeAutoStart(autoStartCtx())
503 else coaching.stop()
504})
505// Replays can begin mid-run, so the reveal + overstay beats key off turns resolved
506// SINCE the tour started, not absolute counts (a fresh first run baselines at 0).
507const tourBaseTurns = ref(0)
508watch(() => coaching.isRunning, (running) => {
509 if (running) tourBaseTurns.value = gameStore.timelineEvents.length
510})
511// Auto-advance on the real action. Picking who & when clears the first beat; the
512// reading beats (the dispatch, the wager) advance on the card's own Next; the
513// send beat waits for an actual resolved turn below.
514watch(
515 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
516 (ready) => { if (ready) coaching.advanceFrom('who-when') }
518// The first turn resolved since the tour began is the payoff beat; advanceTo is
519// monotonic, so it also fast-forwards a player who sent before pressing Next on the
520// readers. One turn further and the loop has plainly landed, so a tour still running
521// has overstayed — it bows out on its own (the engaged reader pressed Done already).
522watch(() => gameStore.timelineEvents.length, (n) => {
523 if (!coaching.isRunning) return
524 if (n >= tourBaseTurns.value + 2) coaching.complete()
525 else if (n > tourBaseTurns.value) coaching.advanceTo('roll-swing')
526})
527// On each new beat, bring its host control into view on phones (md+ shows every
528// zone at once, so this is inert there). Only on a real step change — never yanks
529// a player who tapped elsewhere mid-beat back.
530watch(() => coaching.activeStep?.hostTab, (tab) => {
531 if (tab && !isMd.value) mobileTab.value = tab
532})
533 
534useHead({
535 title: 'Revisionist — Rewrite History',
536 meta: [
537 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
538 ]
539})
540</script>
541 
542<style scoped>
543/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
544 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
545.mobile-tab {
546 display: flex;
547 flex-direction: column;
548 align-items: center;
549 justify-content: center;
550 gap: 2px;
551 padding: 8px 0 9px;
552 color: var(--rv-faint);
553 border-top: 2px solid transparent;
554 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
556.mobile-tab .rv-label { color: inherit; }
557.mobile-tab.is-active {
558 color: var(--rv-accent);
559 border-top-color: var(--rv-accent);
561 
562/* The armed last stand — a quiet accent wash, not a flashing alarm. */
563.stake-armed {
564 border-color: var(--rv-accent);
565 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
567</style>

Checks: nuxt typecheck is clean (the template's references resolve against the script), a production nuxt build compiles the page, and the 463-test unit suite passes.