What changed, and why

The game already has a calibrated pre-send wager indicator: the wager-chip shows the real ⚡ anachronism tier for the idea you're sending. The catch is that it only appears after you run an Archive lookup. Until then you saw a fixed fallback line that stated the general principle and pointed nowhere.

In playtesting most players never ran a lookup, so the genuinely useful signal stayed hidden behind a step they didn't know to take. This change turns that dead fallback into a call to action: it names the asymmetric wager and tells the player exactly how to get the calibrated read — look an idea up in the Archive, which already sits a few lines down in the same compose dock.

The whole change is four lines in one file. The sections below show the two states of the wager indicator, why the fallback is the only honest pre-lookup read, and the two things the edit was careful not to break.

Two states: the calibrated chip, and the nudge

The compose dock renders one of two things before the roll. When the Archive has stamped a "known since" year and the player has picked a contact year, the wager-chip (v-if) shows the real tier. Otherwise the v-else fallback renders. That fallback is the line this PR rewrites.

The new copy keeps the asymmetric wager wording, then appends the nudge, with the call-to-action phrase wearing the ew-accent token so it reads as the actionable part of the sentence.

The refreshed comment (161–166), the calibrated chip (167–170), and the rewritten fallback (171).

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

Why the fallback is the pre-lookup state

The fallback isn't a generic default; it's the precise complement of the chip. wagerRead returns null whenever there's no Archive result or no chosen contact year, and null is exactly what flips the template to the v-else. So the nudge shows in precisely the situation it's written for: the player hasn't looked anything up yet.

wagerRead → null without a known-since stamp or a contact year; that null is what renders the nudge.

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

And the thing the nudge points at is real and close. The Archive lookup box lives in the same step-2 column of the compose dock, so "Look up an idea in the Archive" names an affordance the player can act on without leaving the dock.

ArchiveLookup is right there in the compose dock the nudge sits in.

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

What the edit was careful not to break

Two small things made this a safe one-liner rather than a quiet regression.

First, the data-testid="wager-line" stays. The coaching tour anchors its wager step to the calibrated chip and falls back to the wager-line when the chip isn't present, so dropping or renaming the testid would strand that coach mark.

The wager coach step: anchored to wager-chip, fallbackAnchor to wager-line.

stores/coaching.ts · 245 lines
stores/coaching.ts245 lines · TypeScript
⋯ 56 lines hidden (lines 1–56)
1import { defineStore } from 'pinia'
2 
3/**
4 * Coaching store — the guided first-run tour: its state machine, the step data,
5 * and the one persisted flag that remembers it's done. A teaching layer for a
6 * brand-new player's first real game — it coaches each lever in context as it
7 * becomes relevant, then gets out of the way.
8 *
9 * Deliberately game-AGNOSTIC: it never imports the game store. pages/play.vue is
10 * the single orchestration site — it reads the game and drives this store one
11 * way — so coaching survives resetGame() untouched and the two never tangle.
12 */
13 
14export type CoachStatus = 'idle' | 'running' | 'completed' | 'dismissed'
15 
16export type CoachStepId = 'who-when' | 'craft' | 'wager' | 'send' | 'roll-swing' | 'fuse-stake'
17 
18export interface CoachStep {
19 id: CoachStepId
20 /** Primary anchor: a CSS selector for the live control this beat teaches. */
21 anchor: string
22 /** Used only when the primary isn't on screen (a conditionally-rendered chip). */
23 fallbackAnchor?: string
24 /** The phone tab that hosts this step's control, nudged into view on entry
25 * (md+ shows every zone at once, so the nudge is inert there). */
26 hostTab: 'compose' | 'board' | 'story' | null
27 /** An 11px uppercase kicker (ew-label). */
28 kicker: string
29 /** The lesson: one or two plain sentences that teach the effect, not the wiring. */
30 body: string
31 /** Gate-only steps wait for a real game signal (no Next button) — the player
32 * advances by doing the real thing. Every other step shows a Next/Done so the
33 * tour is never stuck. Gated steps may ALSO auto-advance via a play.vue watcher. */
34 gateOnly?: boolean
35 /** Preferred placement of the card relative to its anchor (auto-flips to fit). */
36 placement: 'top' | 'bottom' | 'left' | 'right'
38 
39/** The first run, beat by beat. The array order is the tour. */
40export const COACH_STEPS: CoachStep[] = [
41 {
42 id: 'who-when',
43 anchor: '[data-testid="figure-picker"]',
44 hostTab: 'compose',
45 kicker: 'Who & when',
46 body: "You're choosing a person and the moment in their life you reach. Slide the year to set when your message lands.",
47 placement: 'left'
48 },
49 {
50 id: 'craft',
51 anchor: '[data-testid="message-input"]',
52 hostTab: 'compose',
53 kicker: 'The dispatch',
54 body: 'One hundred sixty characters, sent into the past. Sharp, period-fit words land better than vague ones — your craft tilts the roll in your favor.',
55 placement: 'left'
56 },
57 {
58 id: 'wager',
59 anchor: '[data-testid="wager-chip"]',
60 fallbackAnchor: '[data-testid="wager-line"]',
61 hostTab: 'compose',
62 kicker: 'Read the wager',
63 body: '⚡ marks words reaching past their era — the timeline swings wider, but losses harder than gains: a bold reach is a bet, not a free boost. ⏳ marks a lone leap into the deep past, diffuse on its own but sharper once you build a chain forward. Read both before you send.',
64 placement: 'bottom'
65 },
⋯ 180 lines hidden (lines 66–245)
66 {
67 id: 'send',
68 anchor: 'button[aria-label="Send message"]',
69 hostTab: 'compose',
70 kicker: 'Send it',
71 body: "Send when you're ready. A twenty-sided die and the era's own logic decide how far history bends. Nothing here is undone — the past keeps what you wrote.",
72 gateOnly: true,
73 placement: 'left'
74 },
75 {
76 id: 'roll-swing',
77 anchor: '[data-testid="dice-roller"]',
78 hostTab: 'board',
79 kicker: 'The roll & the swing',
80 body: 'There is your roll, and the swing it bought. The die set the band; your craft nudged it a point or two. Lucky or not, the world has already moved.',
81 placement: 'right'
82 },
83 {
84 id: 'fuse-stake',
85 anchor: '[data-testid="messages-counter"]',
86 hostTab: null,
87 kicker: 'Five messages, one meter',
88 body: 'Five dispatches to move the meter to your objective. On your last one you can ⚑ stake the timeline — double the swing, both ways. That is the whole game. Go rewrite it.',
89 placement: 'bottom'
90 }
92 
93const STORAGE_KEY = 'everwhen:coaching:v1'
94// Pre-Everwhen key, read once as a fallback so the rename doesn't drop a returning
95// player's tour verdict. writePersisted only writes STORAGE_KEY, so it migrates forward.
96const LEGACY_STORAGE_KEY = 'revisionist:coaching:v1'
97 
98/** The terminal verdicts that suppress auto-start forever (kept distinct only so
99 * tests/analytics can tell a finished tour from a bailed one). */
100type PersistedStatus = 'completed' | 'dismissed'
101 
102// localStorage only exists in the browser — its absence is the SSR guard, so this
103// is inert on the server and never trips hydration. (hydrate() is called from a
104// client-only onMounted regardless; this is the belt to that suspenders.)
105function readPersisted(): PersistedStatus | null {
106 if (typeof localStorage === 'undefined') return null
107 try {
108 const raw = localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY)
109 if (!raw) return null
110 const status = (JSON.parse(raw) as { status?: string }).status
111 if (status === 'completed' || status === 'dismissed') return status
112 } catch {
113 /* malformed, or storage blocked — treat as unseen */
114 }
115 return null
117 
118function writePersisted(status: PersistedStatus): void {
119 if (typeof localStorage === 'undefined') return
120 try {
121 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
122 } catch {
123 /* storage blocked (private mode / quota) — coaching just won't persist */
124 }
126 
127/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
128export interface AutoStartCtx {
129 hasObjective: boolean
130 isPlaying: boolean
131 isFirstTurn: boolean
133 
134export const useCoachingStore = defineStore('coaching', {
135 state: () => ({
136 status: 'idle' as CoachStatus,
137 stepIndex: 0,
138 // false on the server and until the first client read, so SSR and the first
139 // client paint agree (no mark, no flash, no hydration mismatch).
140 hydrated: false,
141 // The verdict already on disk (set at hydrate, or by the first finish). Once
142 // set it is never overwritten — so a replay's later skip/complete can't clobber
143 // the original record, and a finished player can never auto-start again.
144 persistedVerdict: null as PersistedStatus | null
145 }),
146 
147 getters: {
148 steps: (): CoachStep[] => COACH_STEPS,
149 isRunning: (s): boolean => s.status === 'running',
150 activeStep: (s): CoachStep | null =>
151 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
152 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
153 },
154 
155 actions: {
156 /** Read the persisted verdict once, client-side. Idempotent. */
157 hydrate() {
158 if (this.hydrated) return
159 const persisted = readPersisted()
160 if (persisted) {
161 this.status = persisted
162 this.persistedVerdict = persisted
163 }
164 this.hydrated = true
165 },
166 
167 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
168 * is live — never mid-game, never for a returning player (the verdict guard
169 * holds even if a replay later left status at 'idle'). */
170 maybeAutoStart(ctx: AutoStartCtx) {
171 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
172 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
173 },
174 
175 start() {
176 this.status = 'running'
177 this.stepIndex = 0
178 },
179 
180 next() {
181 if (this.status !== 'running') return
182 if (this.stepIndex >= COACH_STEPS.length - 1) {
183 this.complete()
184 return
185 }
186 this.stepIndex++
187 },
188 
189 /** Jump to a later step; never an earlier one — a flapping signal can't
190 * bounce the tour backward. */
191 goTo(index: number) {
192 if (this.status !== 'running') return
193 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
194 this.stepIndex = index
195 },
196 
197 /** Gate primitive: advance one beat only if the named step is the one
198 * showing, so a later game action can't re-fire a finished beat. */
199 advanceFrom(stepId: CoachStepId) {
200 if (this.status !== 'running') return
201 if (this.activeStep?.id === stepId) this.next()
202 },
203 
204 /** Monotonic fast-forward to a step by id — carries a player who blew past
205 * earlier beats straight to the relevant one (e.g. sent before reading). */
206 advanceTo(stepId: CoachStepId) {
207 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
208 if (index >= 0) this.goTo(index)
209 },
210 
211 skip() {
212 this.status = 'dismissed'
213 this.recordVerdict('dismissed')
214 },
215 
216 complete() {
217 this.status = 'completed'
218 this.recordVerdict('completed')
219 },
220 
221 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
222 * skip/complete updates the visible status but leaves the original on disk. */
223 recordVerdict(verdict: PersistedStatus) {
224 if (this.persistedVerdict) return
225 this.persistedVerdict = verdict
226 writePersisted(verdict)
227 },
228 
229 /** End a running tour WITHOUT a verdict — for when its board is torn down
230 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
231 * if one exists (so a replay that's reset can't auto-start again), else idle
232 * (so a brand-new player who reset before finishing is re-coached next run). */
233 stop() {
234 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
235 },
236 
237 /** Re-run on demand (the account popover). In memory only — the persisted
238 * verdict is left intact, so a mid-replay reload won't auto-start and
239 * finishing the replay returns to "never auto-start". */
240 replay() {
241 this.status = 'running'
242 this.stepIndex = 0
243 }
244 }
245})

Second, the wording matches the wager's existing voice. "gains gently, losses hard" is the same phrasing the dice legend already uses, and it preserves the asymmetric framing that issue #132 landed (the swing is not symmetric — a bold reach amplifies losses more than gains). Reusing the issue's ad-hoc "losses hardest" would have reintroduced wording drift across the line family.

The roll legend uses the same 'gains gently, losses hard' phrasing the nudge keeps.

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