What changed, and why

Everwhen's stake ("the last stand") is the final-turn wager: on your fifth and last dispatch, if the gap to the goal is past what a single turn can swing, you may double the resolved swing — both directions, up to the full meter. It is the reason no run is ever mathematically dead. But it was only ever explained at the instant it appeared, and playtests surfaced three legibility gaps (~50 mentions).

1. No advance tease. A player burning mediocre turns was ambushed by the stake on the final turn, never building toward it. 2. The downside was under-read. "both ways" was there, but players read stake = go bigger and missed that a loss doubles too. 3. The "behind but no stake" zone was silent. At 50–99% on the final turn the stake correctly does not appear (a normal throw can still win), but nothing said so — its absence read as defeat.

This change is legibility only. The trigger — the store's canStake getter — is untouched; every new line is read-only computed state plus copy. Blast radius is two components (the gauge and the compose dock) and their tests.

Tease it before the final turn

The gauge already prints an honest pace note ("need +N apiece", escalating to "critical territory"). The tease is a new, deeper rung of that same escalation: when even better-than-Success on every remaining non-final dispatch would still leave the final gap past the per-turn fuse, the staked finale is the likely endgame — so name it now.

The predicate reads the escape route directly: to avoid a staked finale you must close the gap to within the fuse (MAX_PROGRESS_SWING, 50) before the last turn, spread over remaining - 1 dispatches. If that required average tops the Success band's ceiling, you almost certainly can't, so the note flips to "your final dispatch can be staked". It is guarded by remaining > 1 (no divide-by-zero on the final turn) and sits inside the perMessage > ceiling branch, so it is strictly a subset of "critical territory" — never a contradicting message.

The pace note's new innermost rung, plus paceClass picking up 'staked' for the warn styling.

components/ProgressTracker.vue · 217 lines
components/ProgressTracker.vue217 lines · Vue
⋯ 142 lines hidden (lines 1–142)
1<template>
2 <div data-testid="progress-tracker">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="ew-label">Timeline shift</span>
5 <span class="relative inline-flex items-baseline gap-1">
6 <span data-testid="progress-percentage" class="ew-mono text-3xl font-extrabold leading-none" :class="[textClass, { 'pct-pop': popping }]">
7 {{ gameStore.objectiveProgress }}%
8 </span>
9 <span class="ew-mono text-xs ew-faint">→ 100%</span>
10 <span v-if="delta" :key="delta.id" data-testid="progress-delta" class="delta-chip ew-mono"
11 :class="delta.value > 0 ? 'delta-pos' : 'delta-neg'">
12 {{ delta.value > 0 ? '+' : '' }}{{ delta.value }}%
13 </span>
14 </span>
15 </div>
16 
17 <div data-testid="progress-bar" class="ew-track" :class="trackClass">
18 <div class="ew-fill" :style="{ width: gameStore.objectiveProgress + '%', background: fillColor }" />
19 </div>
20 
21 <div class="flex items-baseline justify-between gap-2 mt-1.5 flex-wrap">
22 <p data-testid="progress-status" class="text-xs transition-colors duration-300" :class="statusClass">{{ statusText }}</p>
23 <p v-if="showLedger" data-testid="progress-pace" class="ew-mono text-[11px] ew-faint tabular-nums text-right ml-auto">
24 {{ need }}% to go · {{ gameStore.remainingMessages }} {{ gameStore.remainingMessages === 1 ? 'message' : 'messages' }} left<template v-if="paceNote"> · <span :class="paceClass">{{ paceNote }}</span></template>
25 </p>
26 </div>
27 
28 <!-- The gauge's most important per-turn change, spoken to assistive tech (the visible
29 %/delta/status are not otherwise announced). -->
30 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
31 </div>
32</template>
33 
34<script setup lang="ts">
35/**
36 * ProgressTracker — the TIMELINE SHIFT gauge: a big mono %, a 6px hairline track,
37 * and a status line. The drama is the transient ±delta chip that flies off the % on
38 * every change (animation preserved verbatim).
39 */
40import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
41import { useGameStore } from '~/stores/game'
42import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
43import { MAX_PROGRESS_SWING } from '~/utils/game-config'
44import { SWING_BANDS } from '~/utils/swing-bands'
45import { DiceOutcome } from '~/utils/dice'
46 
47const gameStore = useGameStore()
48const reducedMotion = usePrefersReducedMotion()
49 
50const delta = ref<{ id: number; value: number } | null>(null)
51// The headline % gives a struck-token bump whenever it moves (CSS-only flourish; the
52// rendered number is always the live store value, so it never lags behind a change).
53const popping = ref(false)
54// A polite spoken summary of each shift for screen-reader users.
55const liveMessage = ref('')
56let clearTimer: ReturnType<typeof setTimeout> | null = null
57let popTimer: ReturnType<typeof setTimeout> | null = null
58 
59watch(() => gameStore.objectiveProgress, (now, was) => {
60 const change = now - was
61 if (!change) return
62 delta.value = { id: Date.now() + Math.random(), value: change }
63 liveMessage.value = `Timeline shift ${now} percent, ${change > 0 ? 'up' : 'down'} ${Math.abs(change)}. ${statusText.value}`
64 if (clearTimer) clearTimeout(clearTimer)
65 clearTimer = setTimeout(() => { delta.value = null }, reducedMotion.value ? 900 : 1300)
66 // Restart the bump cleanly even if it was mid-play (false → render → true).
67 popping.value = false
68 if (popTimer) clearTimeout(popTimer)
69 void nextTick(() => {
70 popping.value = true
71 popTimer = setTimeout(() => { popping.value = false }, 360)
72 })
73})
74 
75onUnmounted(() => { if (clearTimer) clearTimeout(clearTimer); if (popTimer) clearTimeout(popTimer) })
76 
77// The gauge crescendos with its own copy. Each stage matches a line of statusText, so
78// the meter HEATS as history bends — a faint stir, ochre, then terracotta — and then
79// snaps to a locked, glowing olive the instant the new timeline holds at 100%. Red
80// stays reserved for negative swings (the delta chip); this ramp is the rising heat.
81type Stage = 'none' | 'crack' | 'bend' | 'hold' | 'brink' | 'complete'
82const stage = computed<Stage>(() => {
83 const p = gameStore.objectiveProgress
84 if (p >= 100) return 'complete'
85 if (p >= 75) return 'brink'
86 if (p >= 50) return 'hold'
87 if (p >= 25) return 'bend'
88 if (p > 0) return 'crack'
89 return 'none'
90})
91 
92const fillColor = computed(() => ({
93 none: 'var(--ew-line)',
94 crack: 'var(--ew-faint)',
95 bend: 'var(--ew-warn)',
96 hold: 'var(--ew-accent)',
97 brink: 'var(--ew-accent)',
98 complete: 'var(--ew-ok)'
99}[stage.value]))
100 
101// The track itself glows as the gauge nears the goal (a pulsing halo on the brink,
102// a steady one once history holds) — the box-shadow escapes the track's clipped fill.
103const trackClass = computed(() => ({
104 'track-brink': stage.value === 'brink',
105 'track-complete': stage.value === 'complete'
106}))
107 
108// The big % and the status line escalate in lockstep with the fill.
109const textClass = computed(() =>
110 stage.value === 'complete' ? 'ew-ok' : stage.value === 'brink' ? 'ew-accent' : 'ew-fg'
112const statusClass = computed(() => {
113 if (gameStore.isLoading) return 'ew-accent font-medium'
114 return stage.value === 'complete' ? 'ew-ok font-semibold'
115 : stage.value === 'brink' ? 'ew-accent font-medium'
116 : 'ew-muted'
117})
118 
119const statusText = computed(() => {
120 // While a turn resolves, the gauge leans in instead of reporting the old, stale
121 // state — so the board never says "unchanged" at the very moment you've acted.
122 if (gameStore.isLoading) return 'The timeline trembles — weighing your move…'
123 const p = gameStore.objectiveProgress
124 if (p === 0) {
125 // A 0% floored AFTER a setback is not a fresh, untouched run — the ledger
126 // tells them apart. A turn that backfires (or nets to nothing) and snaps
127 // back to the start must never read as a no-op (#128).
128 return gameStore.timelineEvents.length > 0
129 ? 'History resists — the timeline holds against you'
130 : 'History stands unchanged…'
131 }
132 if (p < 25) return 'The first cracks appear'
133 if (p < 50) return 'History is bending'
134 if (p < 75) return 'The new timeline takes hold'
135 if (p < 100) return 'On the brink of a new history'
136 return 'History rewritten!'
137})
138 
139// The win condition, made plain: how far to the goal and how many messages remain.
140const showLedger = computed(() => gameStore.timelineEvents.length > 0)
141const need = computed(() => Math.max(0, 100 - gameStore.objectiveProgress))
142 
143// The pace, told honestly. With one message left and the win past the per-turn
144// fuse, the note points at the one move that still reaches it (the staked last
145// stand, ±100 — no position is ever mathematically dead). With more messages
146// left a win is always within stacked-crit reach, so the honest pressure signal
147// is the required average: above the Success band's ceiling means critical luck.
148const paceNote = computed(() => {
149 const remaining = gameStore.remainingMessages
150 if (gameStore.gameStatus !== 'playing' || remaining <= 0 || need.value === 0) return ''
151 const perMessage = Math.ceil(need.value / remaining)
152 const ceiling = SWING_BANDS[DiceOutcome.SUCCESS].max
153 if (remaining === 1 && need.value > MAX_PROGRESS_SWING) return 'the last stand can reach it'
154 if (perMessage > ceiling) {
155 // Tease the stake BEFORE the final turn (#137): escaping a staked finale
156 // would take the remaining non-final dispatches to close the gap to within
157 // the per-turn fuse. When even better-than-Success on every one of them
158 // wouldn't get there, the last stand is the likely endgame — name it now so
159 // it isn't an ambush. (Strictly a subset of critical territory.) With a
160 // 50-point fuse and a 100-point meter this clears only at two messages left
161 // — one turn of warning; teasing earlier, when severely behind with more to
162 // play, is the separate "earlier stake" proposal the issue notes, not this.
163 if (remaining > 1 && (need.value - MAX_PROGRESS_SWING) / (remaining - 1) > ceiling)
164 return `need +${perMessage} apiece — your final dispatch can be staked`
165 return `need +${perMessage} apiece — critical territory`
166 }
167 return `need +${perMessage} apiece`
168})
169const paceClass = computed(() =>
170 paceNote.value.includes('critical') || paceNote.value.includes('last stand') || paceNote.value.includes('staked')
171 ? 'ew-warn font-semibold'
172 : 'ew-faint'
⋯ 45 lines hidden (lines 173–217)
174</script>
175 
176<style scoped>
177/* Crescendo glow — a halo on the track element (its own box-shadow isn't clipped by
178 the fill's overflow). Pulses on the brink, locks steady once history holds. */
179.ew-track { transition: box-shadow var(--ew-dur-2) var(--ew-ease); }
180.track-brink {
181 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-accent) 45%, transparent);
182 animation: track-pulse 1.7s ease-in-out infinite;
184.track-complete {
185 box-shadow: 0 0 14px color-mix(in srgb, var(--ew-ok) 60%, transparent);
187@keyframes track-pulse {
188 0%, 100% { box-shadow: 0 0 6px color-mix(in srgb, var(--ew-accent) 30%, transparent); }
189 50% { box-shadow: 0 0 13px color-mix(in srgb, var(--ew-accent) 68%, transparent); }
191@media (prefers-reduced-motion: reduce) {
192 .track-brink { animation: none; }
194 
195.delta-chip {
196 position: absolute;
197 right: 0;
198 top: -0.35rem;
199 font-size: 0.8rem;
200 font-weight: 800;
201 white-space: nowrap;
202 pointer-events: none;
203 animation: delta-float 1.25s ease-out forwards;
205.delta-pos { color: var(--ew-ok); }
206.delta-neg { color: var(--ew-bad); }
207 
208@keyframes delta-float {
209 0% { opacity: 0; transform: translateY(6px) scale(.9); }
210 20% { opacity: 1; transform: translateY(-2px) scale(1.06); }
211 100% { opacity: 0; transform: translateY(-24px) scale(1); }
213 
214@media (prefers-reduced-motion: reduce) {
215 .delta-chip { animation: none; opacity: 1; transform: translateY(-18px); }
217</style>

Sharpen the downside, show the range

The prompt copy is rewritten to make the wager two-edged on its face. It leads with doubles, both ways (highlighted), then shows the real best/worst swing once doubled, then states the exact gap to close.

The numbers come from stakeRead, which doubles the band table and formats it with the same fmtSwing helper the roll legend uses — so the legend and the stake can't drift. Crucially, the miss spans the full losing envelope: from a doubled Failure down through a doubled crit-fail (−70%), not just the gentle Failure band. The hit spans Success up through a doubled crit (+90%).

The stake prompt (template) and the projection it reads (stakeRead).

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

Fill the 50–99% zone

On the final turn, when the gap is inside the fuse, canStake is correctly false — and now finalReach renders in the stake control's place. It is the exact complement: canStake needs gap > 50, finalReach needs gap > 0 and canStake false, so the two are mutually exclusive with no overlap (the only gap they both skip is the already-won need == 0). The v-else-if makes that structural.

The reassurance line and its computed; the v-else-if pairs it with the stake control.

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

How it's verified

The branching logic — the tease — gets discriminating unit tests: it fires when the finale is likely staked, does not fire when a Success-band turn could still escape, preserves the existing final-turn messages, and never divides by zero on the last turn. The page-level surfaces (the stake copy and the canStake/finalReach handoff) are covered end-to-end, with the boundary pinned at exactly need == 50.

The tease fires when likely-staked; it stays silent when the stake is still escapable.

tests/unit/components/ProgressTracker.spec.ts · 137 lines
tests/unit/components/ProgressTracker.spec.ts137 lines · TypeScript
⋯ 93 lines hidden (lines 1–93)
1import { describe, it, expect, beforeEach } from 'vitest'
2import { mount } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import ProgressTracker from '../../../components/ProgressTracker.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7 
8describe('ProgressTracker', () => {
9 let gameStore: ReturnType<typeof useGameStore>
10 
11 beforeEach(() => {
12 setActivePinia(createPinia())
13 gameStore = useGameStore()
14 })
15 
16 it('displays the current progress percentage', () => {
17 gameStore.objectiveProgress = 45
18 const wrapper = mount(ProgressTracker)
19 expect(wrapper.find('[data-testid="progress-percentage"]').text()).toContain('45%')
20 })
21 
22 it('renders a progress bar and a status line', () => {
23 gameStore.objectiveProgress = 60
24 const wrapper = mount(ProgressTracker)
25 expect(wrapper.find('[data-testid="progress-bar"]').exists()).toBe(true)
26 expect(wrapper.find('[data-testid="progress-status"]').exists()).toBe(true)
27 })
28 
29 it('updates when progress changes', async () => {
30 gameStore.objectiveProgress = 30
31 const wrapper = mount(ProgressTracker)
32 expect(wrapper.text()).toContain('30%')
33 
34 await gameStore.$patch({ objectiveProgress: 70 })
35 await wrapper.vm.$nextTick()
36 
37 expect(wrapper.text()).toContain('70%')
38 })
39 
40 it('renders fine with no objective set', () => {
41 gameStore.currentObjective = null
42 gameStore.objectiveProgress = 0
43 const wrapper = mount(ProgressTracker)
44 expect(wrapper.find('[data-testid="progress-tracker"]').exists()).toBe(true)
45 })
46 
47 it('flashes a +delta chip when progress jumps', async () => {
48 gameStore.objectiveProgress = 20
49 const wrapper = mount(ProgressTracker)
50 expect(wrapper.find('[data-testid="progress-delta"]').exists()).toBe(false)
51 
52 await gameStore.$patch({ objectiveProgress: 42 })
53 await wrapper.vm.$nextTick()
54 
55 const chip = wrapper.find('[data-testid="progress-delta"]')
56 expect(chip.exists()).toBe(true)
57 expect(chip.text()).toContain('+22%')
58 // The headline number stays in lockstep with the store.
59 expect(wrapper.find('[data-testid="progress-percentage"]').text()).toContain('42%')
60 })
61 
62 it('flashes a negative delta chip when progress drops', async () => {
63 gameStore.objectiveProgress = 50
64 const wrapper = mount(ProgressTracker)
65 
66 await gameStore.$patch({ objectiveProgress: 35 })
67 await wrapper.vm.$nextTick()
68 
69 const chip = wrapper.find('[data-testid="progress-delta"]')
70 expect(chip.exists()).toBe(true)
71 expect(chip.text()).toContain('-15%')
72 })
73 
74 it('reads a fresh 0% as untouched history', () => {
75 gameStore.objectiveProgress = 0
76 const wrapper = mount(ProgressTracker)
77 expect(wrapper.find('[data-testid="progress-status"]').text()).toContain('stands unchanged')
78 })
79 
80 it('reads a 0% floored after a setback as resistance, not a no-op (#128)', () => {
81 // A backfired turn snaps progress back to 0, but the ledger recorded it —
82 // the status line must not be byte-identical to a fresh, untouched run.
83 gameStore.objectiveProgress = 0
84 gameStore.addTimelineEvent({
85 figureName: 'X', era: 'e', headline: 'h', detail: 'd',
86 diceRoll: 1, diceOutcome: DiceOutcome.CRITICAL_FAILURE, progressChange: -15
87 })
88 const wrapper = mount(ProgressTracker)
89 const status = wrapper.find('[data-testid="progress-status"]').text()
90 expect(status).not.toContain('stands unchanged')
91 expect(status).toContain('History resists')
92 })
93 
94 // The pace note's stake messaging (#137). A ledger event is needed for the pace
95 // ledger to render at all (showLedger), so each case seeds one.
96 describe('pace note — the stake, teased and tracked', () => {
97 const setup = (progress: number, remaining: number) => {
98 gameStore.objectiveProgress = progress
99 gameStore.remainingMessages = remaining
100 gameStore.addTimelineEvent({
101 figureName: 'X', era: 'e', headline: 'h', detail: 'd',
102 diceRoll: 9, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 1
103 })
104 return mount(ProgressTracker)
105 }
106 const pace = (w: ReturnType<typeof setup>) => w.find('[data-testid="progress-pace"]')
107 
108 it('teases the stake before the final turn when the finale is likely staked', () => {
109 // Two left, 85 to go: even a Success-band 4th turn (≤+30) leaves >50 on
110 // the 5th, so the last stand is the likely endgame — name it now.
111 const w = setup(15, 2)
112 expect(pace(w).text()).toContain('your final dispatch can be staked')
113 // It's an escalation, so it wears the urgent warn styling.
114 expect(pace(w).find('span').classes()).toContain('ew-warn')
115 })
116 
117 it('does not tease while a Success-band turn could still escape the stake', () => {
118 // Two left, 70 to go: a single +20 Success this turn lands the finale
119 // inside the fuse (no stake), so it's critical territory, not a stake tease.
120 const text = pace(setup(30, 2)).text()
121 expect(text).toContain('critical territory')
122 expect(text).not.toContain('staked')
123 })
⋯ 14 lines hidden (lines 124–137)
124 
125 it('points at the last stand on the final turn past the fuse', () => {
126 expect(pace(setup(10, 1)).text()).toContain('the last stand can reach it')
127 })
128 
129 it('on the final turn inside the fuse, asks for the big turn without a stake tease', () => {
130 // One left, 40 to go: a normal throw can still reach it — critical, but
131 // no stake (and no divide-by-zero from remaining - 1).
132 const text = pace(setup(60, 1)).text()
133 expect(text).toContain('critical territory')
134 expect(text).not.toContain('staked')
135 })
136 })
137})