What changed, and why

Revisionist has real mechanical depth — who and when you reach a figure, a D20 nudged by craft, the anachronism wager (⚡), the causal-chain decay (⏳), a five-message fuse, a staked last stand (⚑). It taught all of it passively: a few landing-page beats, a one-line first-turn hint, a foldable roll legend. A first-time player could take their opening turn without knowing why the timeline swung or which levers they hold.

This PR adds a guided first run: a small floating coach-mark that walks a brand-new player through six beats as they play their first real game, then gets out of the way and remembers it's done. It is built to be ignorable — the board stays fully live underneath it, and one "Skip tour" ends it for good.

The shape is three new files and two small edits. A Pinia store holds the beat state machine and the single localStorage flag. A composable turns a control into a live screen rect. A component floats the card and points at the control. pages/play.vue mounts it and drives the beats from real game signals; RunsBalance.vue adds a replay entry. Read the store first — it is where the whole design lives.

FileRole
stores/coaching.tsthe six-beat state machine, copy, and the one persisted flag
composables/useCoachAnchor.tsresolve a control to its live viewport rect, tracked over scroll/resize/layout
components/CoachMark.vuethe floating card + halo; the non-blocking overlay
pages/play.vuemounts it; drives the beats from game signals
components/RunsBalance.vuethe Replay the guided tour entry

The six beats

The tour is this array. Each beat names the control it teaches (anchor), the phone tab that hosts it, an 11px kicker, and one or two plain sentences that teach the effect the player controls — not the AI plumbing behind it. Two details matter for later sections: wager carries a fallbackAnchor (the ⚡ chip only renders some turns, so it falls back to the always-present ⚡ line), and send is gateOnly — it shows no Next and advances only when a real turn resolves.

The ordered beats. The array order is the tour.

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 39 lines hidden (lines 1–39)
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 (rv-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 harder, for you and against you. ⏳ 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 },
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 = 'revisionist:coaching:v1'
94 
⋯ 148 lines hidden (lines 95–242)
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
241 }
242})

Remembering it's done, SSR-safe

Persistence is one localStorage key holding { status }. The guard is typeof localStorage — it only exists in the browser, so this is inert during server-render and never trips hydration (and it stays unit-testable in the node env with a tiny stub). A blocked or malformed read is swallowed and treated as unseen, so private-mode browsing degrades to "tour shows, just doesn't persist."

Read/write the verdict, guarded on the client.

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 95 lines hidden (lines 1–95)
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 (rv-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 harder, for you and against you. ⏳ 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 },
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 = 'revisionist:coaching:v1'
94 
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
⋯ 112 lines hidden (lines 131–242)
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
241 }
242})

The state machine

The store never imports the game store — it stays game-agnostic, and play.vue feeds it the slice of truth it needs (AutoStartCtx). maybeAutoStart fires only for a genuinely fresh first turn that has never been seen; the persistedVerdict guard holds even if a replay later left status at idle.

Advancing has three primitives. next walks one beat (and completes past the end). goTo only ever moves forward, so a flapping signal can't bounce the tour backward. advanceFrom(id) advances only if that beat is the one showing — a later game action can't re-fire a finished beat. advanceTo(id) is the monotonic fast-forward that carries a player who blew past earlier beats straight to the one that matters.

Auto-start guard + the forward-only advance primitives.

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 163 lines hidden (lines 1–163)
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 (rv-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 harder, for you and against you. ⏳ 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 },
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 = 'revisionist:coaching:v1'
94 
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
⋯ 36 lines hidden (lines 207–242)
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
241 }
242})

The terminal verdict is written once. recordVerdict no-ops if a verdict is already on disk, so a replay's skip or auto-complete updates the visible status without clobbering the original record. stop ends a tour whose board was torn down (New timeline) by returning to the resting state — the on-disk verdict if one exists (so a reset replay can't auto-start again), else idle (so a brand-new player who reset before finishing is re-coached next run).

Write-once verdict, and the resting-state stop().

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 207 lines hidden (lines 1–207)
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 (rv-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 harder, for you and against you. ⏳ 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 },
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 = 'revisionist:coaching:v1'
94 
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
⋯ 2 lines hidden (lines 241–242)
241 }
242})

Anchoring a control to a live rect

useCoachAnchor takes an ordered list of selectors and resolves the first visible one to a viewport rect. "Visible" is a zero-area test on getBoundingClientRect: a display:none control (an inactive phone tab) or an unrendered conditional chip both collapse to 0×0, so the caller falls back instead of pointing at a spot no one can see. The ordered list is exactly how a beat falls from the ⚡ chip to the always-present ⚡ line.

measure() rejects hidden targets; resolve() picks the first visible selector.

composables/useCoachAnchor.ts · 131 lines
composables/useCoachAnchor.ts131 lines · TypeScript
⋯ 33 lines hidden (lines 1–33)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges. The
21 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
22 * to an always-present one (the general ⚡ line).
23 *
24 * Returns present=false when no selector resolves to a *visible* element —
25 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
26 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
27 * heavy observers run only while a selector list is set (i.e. while a tour beat
28 * is on screen), so there's no idle cost on the board.
29 */
30export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
31 const rect = ref<AnchorRect | null>(null)
32 const present = ref(false)
33 
34 function measure(el: Element): AnchorRect | null {
35 // A zero-area rect is how a hidden control reads — display:none (an inactive
36 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
37 // so the caller falls back instead of anchoring to a point nobody can see.
38 const r = el.getBoundingClientRect()
39 if (r.width === 0 && r.height === 0) return null
40 return { top: r.top, left: r.left, width: r.width, height: r.height }
41 }
42 
43 function resolve() {
44 const list = selectors.value
45 if (!list || !list.length || typeof document === 'undefined') {
46 rect.value = null
47 present.value = false
48 return
49 }
50 for (const sel of list) {
51 const el = document.querySelector(sel)
52 const measured = el ? measure(el) : null
53 if (measured) {
54 rect.value = measured
55 present.value = true
56 return
57 }
58 }
59 rect.value = null
60 present.value = false
61 }
62 
63 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
64 // per frame. Event-driven layout sync, NOT an animation loop (nothing
65 // self-re-arms), so it needs no reduced-motion guard.
66 let frame = 0
67 function schedule() {
68 if (frame || typeof requestAnimationFrame === 'undefined') {
69 if (typeof requestAnimationFrame === 'undefined') resolve()
70 return
71 }
72 frame = requestAnimationFrame(() => {
73 frame = 0
74 resolve()
75 })
76 }
77 
78 let tracking = false
79 let ro: ResizeObserver | null = null
⋯ 52 lines hidden (lines 80–131)
80 let mo: MutationObserver | null = null
81 
82 function start() {
83 if (tracking || typeof window === 'undefined') return
84 tracking = true
85 resolve()
86 window.addEventListener('scroll', schedule, { capture: true, passive: true })
87 window.addEventListener('resize', schedule, { passive: true })
88 if (typeof ResizeObserver !== 'undefined') {
89 ro = new ResizeObserver(schedule)
90 ro.observe(document.documentElement)
91 }
92 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
93 // without a scroll or resize — watch the tree so the anchor re-resolves.
94 if (typeof MutationObserver !== 'undefined') {
95 mo = new MutationObserver(schedule)
96 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
97 }
98 }
99 
100 function stop() {
101 tracking = false
102 if (frame) {
103 cancelAnimationFrame(frame)
104 frame = 0
105 }
106 if (typeof window !== 'undefined') {
107 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
108 window.removeEventListener('resize', schedule)
109 }
110 ro?.disconnect()
111 ro = null
112 mo?.disconnect()
113 mo = null
114 rect.value = null
115 present.value = false
116 }
117 
118 onMounted(() => {
119 if (selectors.value?.length) start()
120 })
121 
122 watch(selectors, (list) => {
123 if (!list || !list.length) stop()
124 else if (!tracking) start()
125 else resolve()
126 })
127 
128 onBeforeUnmount(stop)
129 
130 return { rect, present }

Tracking is event-driven and lifecycle-scoped. While a beat is on screen it listens to scroll and resize, a ResizeObserver, and a MutationObserver on the body (conditional chips and phone-tab swaps move without a scroll). Every signal is coalesced into one requestAnimationFrame — layout sync, not an animation loop, so it needs no reduced-motion guard. The heavy observers run only while a selector list is set, and stop tears them all down, so there's no idle cost on the board and no leak on unmount.

start()/stop() attach and detach all tracking together.

composables/useCoachAnchor.ts · 131 lines
composables/useCoachAnchor.ts131 lines · TypeScript
⋯ 81 lines hidden (lines 1–81)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges. The
21 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
22 * to an always-present one (the general ⚡ line).
23 *
24 * Returns present=false when no selector resolves to a *visible* element —
25 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
26 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
27 * heavy observers run only while a selector list is set (i.e. while a tour beat
28 * is on screen), so there's no idle cost on the board.
29 */
30export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
31 const rect = ref<AnchorRect | null>(null)
32 const present = ref(false)
33 
34 function measure(el: Element): AnchorRect | null {
35 // A zero-area rect is how a hidden control reads — display:none (an inactive
36 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
37 // so the caller falls back instead of anchoring to a point nobody can see.
38 const r = el.getBoundingClientRect()
39 if (r.width === 0 && r.height === 0) return null
40 return { top: r.top, left: r.left, width: r.width, height: r.height }
41 }
42 
43 function resolve() {
44 const list = selectors.value
45 if (!list || !list.length || typeof document === 'undefined') {
46 rect.value = null
47 present.value = false
48 return
49 }
50 for (const sel of list) {
51 const el = document.querySelector(sel)
52 const measured = el ? measure(el) : null
53 if (measured) {
54 rect.value = measured
55 present.value = true
56 return
57 }
58 }
59 rect.value = null
60 present.value = false
61 }
62 
63 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
64 // per frame. Event-driven layout sync, NOT an animation loop (nothing
65 // self-re-arms), so it needs no reduced-motion guard.
66 let frame = 0
67 function schedule() {
68 if (frame || typeof requestAnimationFrame === 'undefined') {
69 if (typeof requestAnimationFrame === 'undefined') resolve()
70 return
71 }
72 frame = requestAnimationFrame(() => {
73 frame = 0
74 resolve()
75 })
76 }
77 
78 let tracking = false
79 let ro: ResizeObserver | null = null
80 let mo: MutationObserver | null = null
81 
82 function start() {
83 if (tracking || typeof window === 'undefined') return
84 tracking = true
85 resolve()
86 window.addEventListener('scroll', schedule, { capture: true, passive: true })
87 window.addEventListener('resize', schedule, { passive: true })
88 if (typeof ResizeObserver !== 'undefined') {
89 ro = new ResizeObserver(schedule)
90 ro.observe(document.documentElement)
91 }
92 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
93 // without a scroll or resize — watch the tree so the anchor re-resolves.
94 if (typeof MutationObserver !== 'undefined') {
95 mo = new MutationObserver(schedule)
96 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
97 }
98 }
99 
100 function stop() {
101 tracking = false
102 if (frame) {
103 cancelAnimationFrame(frame)
104 frame = 0
105 }
106 if (typeof window !== 'undefined') {
107 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
108 window.removeEventListener('resize', schedule)
109 }
110 ro?.disconnect()
111 ro = null
112 mo?.disconnect()
113 mo = null
114 rect.value = null
115 present.value = false
116 }
117 
⋯ 14 lines hidden (lines 118–131)
118 onMounted(() => {
119 if (selectors.value?.length) start()
120 })
121 
122 watch(selectors, (list) => {
123 if (!list || !list.length) stop()
124 else if (!tracking) start()
125 else resolve()
126 })
127 
128 onBeforeUnmount(stop)
129 
130 return { rect, present }

The coach-mark: a non-blocking overlay

The template is a click-through overlay. The root is pointer-events: none with no scrim; only the small card re-enables pointer events. So every click outside the card — including on the very control being taught — falls straight through to the live board. There is no focus trap and no aria-modal: the card is an announced role=status live region, not a dialog. The halo is drawn here from the target's rect (not as an outline on the target) so an ancestor's overflow can't clip it.

The overlay, the halo, the card, and its two real <button>s.

components/CoachMark.vue · 242 lines
components/CoachMark.vue242 lines · Vue
1<template>
2 <div v-if="visible" class="coach-root">
3 <!-- A non-blocking halo around the live control — draws the eye without a
4 scrim. Drawn here (fixed, from the target's rect) rather than as an
5 outline on the target itself, so an ancestor's overflow can't clip it. -->
6 <div v-if="anchor.present.value && haloStyle" class="coach-halo" :style="haloStyle" aria-hidden="true" />
7 
8 <!-- The coaching card. The ONLY thing in this overlay that takes pointer
9 events — every click outside it falls straight through to the live board. -->
10 <div ref="cardEl" data-testid="coach-mark" class="coach-mark"
11 :style="cardStyle" :data-step="step?.id"
12 role="status" aria-live="polite" aria-atomic="true" aria-label="Guided coaching">
13 <p class="rv-label coach-kicker">{{ step?.kicker }}</p>
14 <p class="coach-body rv-fg">{{ step?.body }}</p>
15 <div class="coach-foot">
16 <span class="coach-count rv-faint" aria-hidden="true">{{ stepIndex + 1 }} / {{ total }}</span>
17 <div class="coach-actions">
18 <button v-if="!step?.gateOnly" type="button" data-testid="coach-next"
19 class="rv-btn rv-btn--primary coach-next" @click="onNext">
20 {{ isLastStep ? 'Done' : 'Next' }}<span v-if="!isLastStep" aria-hidden="true"></span>
21 </button>
22 <span v-else class="coach-waiting rv-faint" aria-hidden="true">send to continue</span>
23 <button type="button" data-testid="coach-skip" class="coach-skip rv-hover-fg" @click="onSkip">Skip tour</button>
24 </div>
25 </div>
26 </div>
27 </div>
28</template>
⋯ 214 lines hidden (lines 29–242)
29 
30<script setup lang="ts">
31/**
32 * CoachMark — the single floating callout for the guided first run. Reads the
33 * coaching store for the active beat, resolves that beat's control via
34 * useCoachAnchor, and floats a small ledger card beside it with a soft halo.
35 *
36 * Non-blocking by construction: the overlay root is pointer-events:none with no
37 * scrim, and only the card re-enables pointer events — so the board (including
38 * the very control being taught) stays fully live. No focus trap, no modal lock.
39 * Mounted once in pages/play.vue, inside <ClientOnly>.
40 */
41import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
42import { useCoachingStore } from '~/stores/coaching'
43import { useGameStore } from '~/stores/game'
44import { useCoachAnchor, type AnchorRect } from '~/composables/useCoachAnchor'
45 
46const coaching = useCoachingStore()
47const gameStore = useGameStore()
48 
49const step = computed(() => coaching.activeStep)
50const stepIndex = computed(() => coaching.stepIndex)
51const total = computed(() => coaching.steps.length)
52const isLastStep = computed(() => coaching.isLastStep)
53 
54// While a turn resolves the board owns the eye (the shaking die, the sweep), so
55// the mark stands down and returns on the next beat — also dodging any flash of
56// the spent "Send it" card between the roll landing and the reveal step entering.
57const mounted = ref(false)
58onMounted(() => { mounted.value = true })
59const visible = computed(() => mounted.value && coaching.isRunning && !gameStore.isLoading)
60 
61// The active beat's anchors: the control it teaches, then an always-present
62// fallback for conditionally-rendered ones (the ⚡ chip → the general ⚡ line).
63const selectorList = computed<string[] | null>(() => {
64 const s = step.value
65 if (!s) return null
66 return s.fallbackAnchor ? [s.anchor, s.fallbackAnchor] : [s.anchor]
67})
68const anchor = useCoachAnchor(selectorList)
69 
70// ── Positioning ──────────────────────────────────────────────────────────
71const GAP = 12 // breathing room between the control and the card
72const MARGIN = 10 // keep the card this far inside the viewport
73 
74// A reactive md breakpoint: the corner-fallback branch of cardStyle has no rect to
75// recompute on, so it would otherwise miss a resize across 768px. The anchored branch
76// already recomputes (its rect moves on resize).
77const isMd = ref(false)
78let mdMql: MediaQueryList | null = null
79function syncMd() { isMd.value = mdMql?.matches ?? false }
80onMounted(() => {
81 if (typeof window === 'undefined' || !window.matchMedia) return
82 mdMql = window.matchMedia('(min-width: 768px)')
83 isMd.value = mdMql.matches
84 mdMql.addEventListener('change', syncMd)
85})
86 
87const cardEl = ref<HTMLElement | null>(null)
88const cardSize = ref({ w: 288, h: 170 }) // a sane estimate until measured
89let cardRO: ResizeObserver | null = null
90watch(cardEl, (el) => {
91 cardRO?.disconnect()
92 cardRO = null
93 if (el && typeof ResizeObserver !== 'undefined') {
94 cardRO = new ResizeObserver(() => { cardSize.value = { w: el.offsetWidth, h: el.offsetHeight } })
95 cardRO.observe(el)
96 }
97})
98 
99type Placement = 'top' | 'bottom' | 'left' | 'right'
100const OPPOSITE: Record<Placement, Placement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
101 
102function placeAt(p: Placement, r: AnchorRect, w: number, h: number) {
103 switch (p) {
104 case 'bottom': return { top: r.top + r.height + GAP, left: r.left + r.width / 2 - w / 2 }
105 case 'top': return { top: r.top - h - GAP, left: r.left + r.width / 2 - w / 2 }
106 case 'right': return { top: r.top + r.height / 2 - h / 2, left: r.left + r.width + GAP }
107 case 'left': return { top: r.top + r.height / 2 - h / 2, left: r.left - w - GAP }
108 }
110function fits(c: { top: number; left: number }, w: number, h: number, vw: number, vh: number) {
111 return c.top >= MARGIN && c.left >= MARGIN && c.top + h <= vh - MARGIN && c.left + w <= vw - MARGIN
113const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
114 
115const cardStyle = computed(() => {
116 if (typeof window === 'undefined') return {}
117 const r = anchor.present.value ? anchor.rect.value : null
118 const { w, h } = cardSize.value
119 const vw = window.innerWidth
120 const vh = window.innerHeight
121 if (!r) {
122 // Corner fallback: bottom-center above the phone tab bar, bottom-right wider.
123 return isMd.value
124 ? { top: 'auto', left: 'auto', right: '16px', bottom: '16px' }
125 : { top: 'auto', left: '50%', right: 'auto', bottom: '76px', transform: 'translateX(-50%)' }
126 }
127 const pref = step.value?.placement ?? 'bottom'
128 const order: Placement[] = [pref, OPPOSITE[pref], 'bottom', 'top', 'right', 'left']
129 let chosen = placeAt(pref, r, w, h)
130 for (const p of order) {
131 const c = placeAt(p, r, w, h)
132 chosen = c
133 if (fits(c, w, h, vw, vh)) break
134 }
135 return {
136 top: `${clamp(chosen.top, MARGIN, Math.max(MARGIN, vh - h - MARGIN))}px`,
137 left: `${clamp(chosen.left, MARGIN, Math.max(MARGIN, vw - w - MARGIN))}px`,
138 right: 'auto',
139 bottom: 'auto'
140 }
141})
142 
143const haloStyle = computed(() => {
144 const r = anchor.rect.value
145 if (!r) return null
146 const pad = 4
147 return {
148 top: `${r.top - pad}px`,
149 left: `${r.left - pad}px`,
150 width: `${r.width + pad * 2}px`,
151 height: `${r.height + pad * 2}px`
152 }
153})
154 
155// ── Controls ─────────────────────────────────────────────────────────────
156function onNext() { coaching.next() }
157function onSkip() { coaching.skip() }
158 
159// Escape dismisses (mirrors the account popover) — documented by the visible
160// "Skip tour". Guarded so it only fires while a beat is actually showing.
161function onKeydown(e: KeyboardEvent) {
162 if (e.key === 'Escape' && coaching.isRunning) coaching.skip()
164onMounted(() => document.addEventListener('keydown', onKeydown))
165onBeforeUnmount(() => {
166 document.removeEventListener('keydown', onKeydown)
167 cardRO?.disconnect()
168 mdMql?.removeEventListener('change', syncMd)
169})
170</script>
171 
172<style scoped>
173/* A click-through overlay: no scrim, no background. Above the board + phone tab
174 bar (z-30), below the account popover (z-40) and the end-screen (z-50). */
175.coach-root {
176 position: fixed;
177 inset: 0;
178 z-index: 35;
179 pointer-events: none;
181 
182/* The halo — the focus-ring vocabulary already in main.css, reused as a pointer. */
183.coach-halo {
184 position: fixed;
185 border: 2px solid var(--rv-accent);
186 border-radius: 5px;
187 box-shadow: 0 0 0 4px color-mix(in srgb, var(--rv-accent) 14%, transparent);
188 pointer-events: none;
189 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease),
190 width var(--rv-dur-2) var(--rv-ease), height var(--rv-dur-2) var(--rv-ease);
192 
193/* The card — a struck ledger card (echoes .rv-card), the only interactive surface. */
194.coach-mark {
195 position: fixed;
196 width: 288px;
197 max-width: calc(100vw - 24px);
198 pointer-events: auto;
199 background: var(--rv-tint);
200 border: 1px solid var(--rv-line);
201 border-radius: 4px;
202 box-shadow: inset 0 0 0 3px var(--rv-tint), inset 0 0 0 4px var(--rv-line),
203 0 10px 28px color-mix(in srgb, var(--rv-fg) 16%, transparent);
204 padding: 12px 13px 11px;
205 animation: coach-in var(--rv-dur-2) var(--rv-ease);
206 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease);
208@keyframes coach-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
209 
210.coach-kicker { color: var(--rv-accent); margin-bottom: 5px; }
211.coach-body { font-size: 13px; line-height: 1.5; }
212 
213.coach-foot {
214 display: flex;
215 align-items: center;
216 justify-content: space-between;
217 gap: 10px;
218 margin-top: 11px;
219 padding-top: 9px;
220 border-top: 1px solid var(--rv-line);
222.coach-count { font-family: var(--rv-mono); font-size: 11px; }
223.coach-actions { display: flex; align-items: center; gap: 10px; }
224.coach-next { padding: 4px 12px; font-size: 13px; }
225.coach-waiting { font-size: 11px; font-style: italic; }
226.coach-skip {
227 font-size: 11px;
228 color: var(--rv-faint);
229 background: transparent;
230 border: 0;
231 cursor: pointer;
232 padding: 4px 2px;
234.coach-skip:hover { color: var(--rv-fg); }
235.coach-skip:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
236 
237/* This component is scoped, so the central main.css reduced-motion block can't
238 reach it — neutralize its motion here. Position changes snap; no entrance. */
239@media (prefers-reduced-motion: reduce) {
240 .coach-mark, .coach-halo { transition: none; animation: none; }
242</style>

The card stands down while a turn resolves (the board owns the eye then, and it dodges a flash of the spent "Send it" card between the roll landing and the reveal beat). Placement tries the beat's preferred side, then flips through the others to the first that fits the viewport, then clamps — and falls back to a fixed corner when there's no anchor at all. A reactive isMd drives that corner branch so it re-evaluates across the 768px breakpoint (the anchored branch already recomputes when its rect moves).

Flip-to-fit, clamp, and the corner fallback.

components/CoachMark.vue · 242 lines
components/CoachMark.vue242 lines · Vue
⋯ 101 lines hidden (lines 1–101)
1<template>
2 <div v-if="visible" class="coach-root">
3 <!-- A non-blocking halo around the live control — draws the eye without a
4 scrim. Drawn here (fixed, from the target's rect) rather than as an
5 outline on the target itself, so an ancestor's overflow can't clip it. -->
6 <div v-if="anchor.present.value && haloStyle" class="coach-halo" :style="haloStyle" aria-hidden="true" />
7 
8 <!-- The coaching card. The ONLY thing in this overlay that takes pointer
9 events — every click outside it falls straight through to the live board. -->
10 <div ref="cardEl" data-testid="coach-mark" class="coach-mark"
11 :style="cardStyle" :data-step="step?.id"
12 role="status" aria-live="polite" aria-atomic="true" aria-label="Guided coaching">
13 <p class="rv-label coach-kicker">{{ step?.kicker }}</p>
14 <p class="coach-body rv-fg">{{ step?.body }}</p>
15 <div class="coach-foot">
16 <span class="coach-count rv-faint" aria-hidden="true">{{ stepIndex + 1 }} / {{ total }}</span>
17 <div class="coach-actions">
18 <button v-if="!step?.gateOnly" type="button" data-testid="coach-next"
19 class="rv-btn rv-btn--primary coach-next" @click="onNext">
20 {{ isLastStep ? 'Done' : 'Next' }}<span v-if="!isLastStep" aria-hidden="true"></span>
21 </button>
22 <span v-else class="coach-waiting rv-faint" aria-hidden="true">send to continue</span>
23 <button type="button" data-testid="coach-skip" class="coach-skip rv-hover-fg" @click="onSkip">Skip tour</button>
24 </div>
25 </div>
26 </div>
27 </div>
28</template>
29 
30<script setup lang="ts">
31/**
32 * CoachMark — the single floating callout for the guided first run. Reads the
33 * coaching store for the active beat, resolves that beat's control via
34 * useCoachAnchor, and floats a small ledger card beside it with a soft halo.
35 *
36 * Non-blocking by construction: the overlay root is pointer-events:none with no
37 * scrim, and only the card re-enables pointer events — so the board (including
38 * the very control being taught) stays fully live. No focus trap, no modal lock.
39 * Mounted once in pages/play.vue, inside <ClientOnly>.
40 */
41import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
42import { useCoachingStore } from '~/stores/coaching'
43import { useGameStore } from '~/stores/game'
44import { useCoachAnchor, type AnchorRect } from '~/composables/useCoachAnchor'
45 
46const coaching = useCoachingStore()
47const gameStore = useGameStore()
48 
49const step = computed(() => coaching.activeStep)
50const stepIndex = computed(() => coaching.stepIndex)
51const total = computed(() => coaching.steps.length)
52const isLastStep = computed(() => coaching.isLastStep)
53 
54// While a turn resolves the board owns the eye (the shaking die, the sweep), so
55// the mark stands down and returns on the next beat — also dodging any flash of
56// the spent "Send it" card between the roll landing and the reveal step entering.
57const mounted = ref(false)
58onMounted(() => { mounted.value = true })
59const visible = computed(() => mounted.value && coaching.isRunning && !gameStore.isLoading)
60 
61// The active beat's anchors: the control it teaches, then an always-present
62// fallback for conditionally-rendered ones (the ⚡ chip → the general ⚡ line).
63const selectorList = computed<string[] | null>(() => {
64 const s = step.value
65 if (!s) return null
66 return s.fallbackAnchor ? [s.anchor, s.fallbackAnchor] : [s.anchor]
67})
68const anchor = useCoachAnchor(selectorList)
69 
70// ── Positioning ──────────────────────────────────────────────────────────
71const GAP = 12 // breathing room between the control and the card
72const MARGIN = 10 // keep the card this far inside the viewport
73 
74// A reactive md breakpoint: the corner-fallback branch of cardStyle has no rect to
75// recompute on, so it would otherwise miss a resize across 768px. The anchored branch
76// already recomputes (its rect moves on resize).
77const isMd = ref(false)
78let mdMql: MediaQueryList | null = null
79function syncMd() { isMd.value = mdMql?.matches ?? false }
80onMounted(() => {
81 if (typeof window === 'undefined' || !window.matchMedia) return
82 mdMql = window.matchMedia('(min-width: 768px)')
83 isMd.value = mdMql.matches
84 mdMql.addEventListener('change', syncMd)
85})
86 
87const cardEl = ref<HTMLElement | null>(null)
88const cardSize = ref({ w: 288, h: 170 }) // a sane estimate until measured
89let cardRO: ResizeObserver | null = null
90watch(cardEl, (el) => {
91 cardRO?.disconnect()
92 cardRO = null
93 if (el && typeof ResizeObserver !== 'undefined') {
94 cardRO = new ResizeObserver(() => { cardSize.value = { w: el.offsetWidth, h: el.offsetHeight } })
95 cardRO.observe(el)
96 }
97})
98 
99type Placement = 'top' | 'bottom' | 'left' | 'right'
100const OPPOSITE: Record<Placement, Placement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
101 
102function placeAt(p: Placement, r: AnchorRect, w: number, h: number) {
103 switch (p) {
104 case 'bottom': return { top: r.top + r.height + GAP, left: r.left + r.width / 2 - w / 2 }
105 case 'top': return { top: r.top - h - GAP, left: r.left + r.width / 2 - w / 2 }
106 case 'right': return { top: r.top + r.height / 2 - h / 2, left: r.left + r.width + GAP }
107 case 'left': return { top: r.top + r.height / 2 - h / 2, left: r.left - w - GAP }
108 }
110function fits(c: { top: number; left: number }, w: number, h: number, vw: number, vh: number) {
111 return c.top >= MARGIN && c.left >= MARGIN && c.top + h <= vh - MARGIN && c.left + w <= vw - MARGIN
113const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
114 
115const cardStyle = computed(() => {
116 if (typeof window === 'undefined') return {}
117 const r = anchor.present.value ? anchor.rect.value : null
118 const { w, h } = cardSize.value
119 const vw = window.innerWidth
120 const vh = window.innerHeight
121 if (!r) {
122 // Corner fallback: bottom-center above the phone tab bar, bottom-right wider.
123 return isMd.value
124 ? { top: 'auto', left: 'auto', right: '16px', bottom: '16px' }
125 : { top: 'auto', left: '50%', right: 'auto', bottom: '76px', transform: 'translateX(-50%)' }
126 }
127 const pref = step.value?.placement ?? 'bottom'
128 const order: Placement[] = [pref, OPPOSITE[pref], 'bottom', 'top', 'right', 'left']
129 let chosen = placeAt(pref, r, w, h)
130 for (const p of order) {
131 const c = placeAt(p, r, w, h)
132 chosen = c
133 if (fits(c, w, h, vw, vh)) break
134 }
135 return {
136 top: `${clamp(chosen.top, MARGIN, Math.max(MARGIN, vh - h - MARGIN))}px`,
137 left: `${clamp(chosen.left, MARGIN, Math.max(MARGIN, vw - w - MARGIN))}px`,
138 right: 'auto',
139 bottom: 'auto'
140 }
141})
⋯ 101 lines hidden (lines 142–242)
142 
143const haloStyle = computed(() => {
144 const r = anchor.rect.value
145 if (!r) return null
146 const pad = 4
147 return {
148 top: `${r.top - pad}px`,
149 left: `${r.left - pad}px`,
150 width: `${r.width + pad * 2}px`,
151 height: `${r.height + pad * 2}px`
152 }
153})
154 
155// ── Controls ─────────────────────────────────────────────────────────────
156function onNext() { coaching.next() }
157function onSkip() { coaching.skip() }
158 
159// Escape dismisses (mirrors the account popover) — documented by the visible
160// "Skip tour". Guarded so it only fires while a beat is actually showing.
161function onKeydown(e: KeyboardEvent) {
162 if (e.key === 'Escape' && coaching.isRunning) coaching.skip()
164onMounted(() => document.addEventListener('keydown', onKeydown))
165onBeforeUnmount(() => {
166 document.removeEventListener('keydown', onKeydown)
167 cardRO?.disconnect()
168 mdMql?.removeEventListener('change', syncMd)
169})
170</script>
171 
172<style scoped>
173/* A click-through overlay: no scrim, no background. Above the board + phone tab
174 bar (z-30), below the account popover (z-40) and the end-screen (z-50). */
175.coach-root {
176 position: fixed;
177 inset: 0;
178 z-index: 35;
179 pointer-events: none;
181 
182/* The halo — the focus-ring vocabulary already in main.css, reused as a pointer. */
183.coach-halo {
184 position: fixed;
185 border: 2px solid var(--rv-accent);
186 border-radius: 5px;
187 box-shadow: 0 0 0 4px color-mix(in srgb, var(--rv-accent) 14%, transparent);
188 pointer-events: none;
189 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease),
190 width var(--rv-dur-2) var(--rv-ease), height var(--rv-dur-2) var(--rv-ease);
192 
193/* The card — a struck ledger card (echoes .rv-card), the only interactive surface. */
194.coach-mark {
195 position: fixed;
196 width: 288px;
197 max-width: calc(100vw - 24px);
198 pointer-events: auto;
199 background: var(--rv-tint);
200 border: 1px solid var(--rv-line);
201 border-radius: 4px;
202 box-shadow: inset 0 0 0 3px var(--rv-tint), inset 0 0 0 4px var(--rv-line),
203 0 10px 28px color-mix(in srgb, var(--rv-fg) 16%, transparent);
204 padding: 12px 13px 11px;
205 animation: coach-in var(--rv-dur-2) var(--rv-ease);
206 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease);
208@keyframes coach-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
209 
210.coach-kicker { color: var(--rv-accent); margin-bottom: 5px; }
211.coach-body { font-size: 13px; line-height: 1.5; }
212 
213.coach-foot {
214 display: flex;
215 align-items: center;
216 justify-content: space-between;
217 gap: 10px;
218 margin-top: 11px;
219 padding-top: 9px;
220 border-top: 1px solid var(--rv-line);
222.coach-count { font-family: var(--rv-mono); font-size: 11px; }
223.coach-actions { display: flex; align-items: center; gap: 10px; }
224.coach-next { padding: 4px 12px; font-size: 13px; }
225.coach-waiting { font-size: 11px; font-style: italic; }
226.coach-skip {
227 font-size: 11px;
228 color: var(--rv-faint);
229 background: transparent;
230 border: 0;
231 cursor: pointer;
232 padding: 4px 2px;
234.coach-skip:hover { color: var(--rv-fg); }
235.coach-skip:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
236 
237/* This component is scoped, so the central main.css reduced-motion block can't
238 reach it — neutralize its motion here. Position changes snap; no entrance. */
239@media (prefers-reduced-motion: reduce) {
240 .coach-mark, .coach-halo { transition: none; animation: none; }
242</style>

Escape dismisses (mirroring the account popover), documented by the visible Skip tour. Because the component is scoped, the central reduced-motion block in main.css can't reach it, so it ships its own: under reduced motion the card and halo neither animate nor transition.

Escape-to-dismiss, and the component-local reduced-motion guard.

components/CoachMark.vue · 242 lines
components/CoachMark.vue242 lines · Vue
⋯ 159 lines hidden (lines 1–159)
1<template>
2 <div v-if="visible" class="coach-root">
3 <!-- A non-blocking halo around the live control — draws the eye without a
4 scrim. Drawn here (fixed, from the target's rect) rather than as an
5 outline on the target itself, so an ancestor's overflow can't clip it. -->
6 <div v-if="anchor.present.value && haloStyle" class="coach-halo" :style="haloStyle" aria-hidden="true" />
7 
8 <!-- The coaching card. The ONLY thing in this overlay that takes pointer
9 events — every click outside it falls straight through to the live board. -->
10 <div ref="cardEl" data-testid="coach-mark" class="coach-mark"
11 :style="cardStyle" :data-step="step?.id"
12 role="status" aria-live="polite" aria-atomic="true" aria-label="Guided coaching">
13 <p class="rv-label coach-kicker">{{ step?.kicker }}</p>
14 <p class="coach-body rv-fg">{{ step?.body }}</p>
15 <div class="coach-foot">
16 <span class="coach-count rv-faint" aria-hidden="true">{{ stepIndex + 1 }} / {{ total }}</span>
17 <div class="coach-actions">
18 <button v-if="!step?.gateOnly" type="button" data-testid="coach-next"
19 class="rv-btn rv-btn--primary coach-next" @click="onNext">
20 {{ isLastStep ? 'Done' : 'Next' }}<span v-if="!isLastStep" aria-hidden="true"></span>
21 </button>
22 <span v-else class="coach-waiting rv-faint" aria-hidden="true">send to continue</span>
23 <button type="button" data-testid="coach-skip" class="coach-skip rv-hover-fg" @click="onSkip">Skip tour</button>
24 </div>
25 </div>
26 </div>
27 </div>
28</template>
29 
30<script setup lang="ts">
31/**
32 * CoachMark — the single floating callout for the guided first run. Reads the
33 * coaching store for the active beat, resolves that beat's control via
34 * useCoachAnchor, and floats a small ledger card beside it with a soft halo.
35 *
36 * Non-blocking by construction: the overlay root is pointer-events:none with no
37 * scrim, and only the card re-enables pointer events — so the board (including
38 * the very control being taught) stays fully live. No focus trap, no modal lock.
39 * Mounted once in pages/play.vue, inside <ClientOnly>.
40 */
41import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
42import { useCoachingStore } from '~/stores/coaching'
43import { useGameStore } from '~/stores/game'
44import { useCoachAnchor, type AnchorRect } from '~/composables/useCoachAnchor'
45 
46const coaching = useCoachingStore()
47const gameStore = useGameStore()
48 
49const step = computed(() => coaching.activeStep)
50const stepIndex = computed(() => coaching.stepIndex)
51const total = computed(() => coaching.steps.length)
52const isLastStep = computed(() => coaching.isLastStep)
53 
54// While a turn resolves the board owns the eye (the shaking die, the sweep), so
55// the mark stands down and returns on the next beat — also dodging any flash of
56// the spent "Send it" card between the roll landing and the reveal step entering.
57const mounted = ref(false)
58onMounted(() => { mounted.value = true })
59const visible = computed(() => mounted.value && coaching.isRunning && !gameStore.isLoading)
60 
61// The active beat's anchors: the control it teaches, then an always-present
62// fallback for conditionally-rendered ones (the ⚡ chip → the general ⚡ line).
63const selectorList = computed<string[] | null>(() => {
64 const s = step.value
65 if (!s) return null
66 return s.fallbackAnchor ? [s.anchor, s.fallbackAnchor] : [s.anchor]
67})
68const anchor = useCoachAnchor(selectorList)
69 
70// ── Positioning ──────────────────────────────────────────────────────────
71const GAP = 12 // breathing room between the control and the card
72const MARGIN = 10 // keep the card this far inside the viewport
73 
74// A reactive md breakpoint: the corner-fallback branch of cardStyle has no rect to
75// recompute on, so it would otherwise miss a resize across 768px. The anchored branch
76// already recomputes (its rect moves on resize).
77const isMd = ref(false)
78let mdMql: MediaQueryList | null = null
79function syncMd() { isMd.value = mdMql?.matches ?? false }
80onMounted(() => {
81 if (typeof window === 'undefined' || !window.matchMedia) return
82 mdMql = window.matchMedia('(min-width: 768px)')
83 isMd.value = mdMql.matches
84 mdMql.addEventListener('change', syncMd)
85})
86 
87const cardEl = ref<HTMLElement | null>(null)
88const cardSize = ref({ w: 288, h: 170 }) // a sane estimate until measured
89let cardRO: ResizeObserver | null = null
90watch(cardEl, (el) => {
91 cardRO?.disconnect()
92 cardRO = null
93 if (el && typeof ResizeObserver !== 'undefined') {
94 cardRO = new ResizeObserver(() => { cardSize.value = { w: el.offsetWidth, h: el.offsetHeight } })
95 cardRO.observe(el)
96 }
97})
98 
99type Placement = 'top' | 'bottom' | 'left' | 'right'
100const OPPOSITE: Record<Placement, Placement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
101 
102function placeAt(p: Placement, r: AnchorRect, w: number, h: number) {
103 switch (p) {
104 case 'bottom': return { top: r.top + r.height + GAP, left: r.left + r.width / 2 - w / 2 }
105 case 'top': return { top: r.top - h - GAP, left: r.left + r.width / 2 - w / 2 }
106 case 'right': return { top: r.top + r.height / 2 - h / 2, left: r.left + r.width + GAP }
107 case 'left': return { top: r.top + r.height / 2 - h / 2, left: r.left - w - GAP }
108 }
110function fits(c: { top: number; left: number }, w: number, h: number, vw: number, vh: number) {
111 return c.top >= MARGIN && c.left >= MARGIN && c.top + h <= vh - MARGIN && c.left + w <= vw - MARGIN
113const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
114 
115const cardStyle = computed(() => {
116 if (typeof window === 'undefined') return {}
117 const r = anchor.present.value ? anchor.rect.value : null
118 const { w, h } = cardSize.value
119 const vw = window.innerWidth
120 const vh = window.innerHeight
121 if (!r) {
122 // Corner fallback: bottom-center above the phone tab bar, bottom-right wider.
123 return isMd.value
124 ? { top: 'auto', left: 'auto', right: '16px', bottom: '16px' }
125 : { top: 'auto', left: '50%', right: 'auto', bottom: '76px', transform: 'translateX(-50%)' }
126 }
127 const pref = step.value?.placement ?? 'bottom'
128 const order: Placement[] = [pref, OPPOSITE[pref], 'bottom', 'top', 'right', 'left']
129 let chosen = placeAt(pref, r, w, h)
130 for (const p of order) {
131 const c = placeAt(p, r, w, h)
132 chosen = c
133 if (fits(c, w, h, vw, vh)) break
134 }
135 return {
136 top: `${clamp(chosen.top, MARGIN, Math.max(MARGIN, vh - h - MARGIN))}px`,
137 left: `${clamp(chosen.left, MARGIN, Math.max(MARGIN, vw - w - MARGIN))}px`,
138 right: 'auto',
139 bottom: 'auto'
140 }
141})
142 
143const haloStyle = computed(() => {
144 const r = anchor.rect.value
145 if (!r) return null
146 const pad = 4
147 return {
148 top: `${r.top - pad}px`,
149 left: `${r.left - pad}px`,
150 width: `${r.width + pad * 2}px`,
151 height: `${r.height + pad * 2}px`
152 }
153})
154 
155// ── Controls ─────────────────────────────────────────────────────────────
156function onNext() { coaching.next() }
157function onSkip() { coaching.skip() }
158 
159// Escape dismisses (mirrors the account popover) — documented by the visible
160// "Skip tour". Guarded so it only fires while a beat is actually showing.
161function onKeydown(e: KeyboardEvent) {
162 if (e.key === 'Escape' && coaching.isRunning) coaching.skip()
⋯ 74 lines hidden (lines 164–237)
164onMounted(() => document.addEventListener('keydown', onKeydown))
165onBeforeUnmount(() => {
166 document.removeEventListener('keydown', onKeydown)
167 cardRO?.disconnect()
168 mdMql?.removeEventListener('change', syncMd)
169})
170</script>
171 
172<style scoped>
173/* A click-through overlay: no scrim, no background. Above the board + phone tab
174 bar (z-30), below the account popover (z-40) and the end-screen (z-50). */
175.coach-root {
176 position: fixed;
177 inset: 0;
178 z-index: 35;
179 pointer-events: none;
181 
182/* The halo — the focus-ring vocabulary already in main.css, reused as a pointer. */
183.coach-halo {
184 position: fixed;
185 border: 2px solid var(--rv-accent);
186 border-radius: 5px;
187 box-shadow: 0 0 0 4px color-mix(in srgb, var(--rv-accent) 14%, transparent);
188 pointer-events: none;
189 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease),
190 width var(--rv-dur-2) var(--rv-ease), height var(--rv-dur-2) var(--rv-ease);
192 
193/* The card — a struck ledger card (echoes .rv-card), the only interactive surface. */
194.coach-mark {
195 position: fixed;
196 width: 288px;
197 max-width: calc(100vw - 24px);
198 pointer-events: auto;
199 background: var(--rv-tint);
200 border: 1px solid var(--rv-line);
201 border-radius: 4px;
202 box-shadow: inset 0 0 0 3px var(--rv-tint), inset 0 0 0 4px var(--rv-line),
203 0 10px 28px color-mix(in srgb, var(--rv-fg) 16%, transparent);
204 padding: 12px 13px 11px;
205 animation: coach-in var(--rv-dur-2) var(--rv-ease);
206 transition: top var(--rv-dur-2) var(--rv-ease), left var(--rv-dur-2) var(--rv-ease);
208@keyframes coach-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
209 
210.coach-kicker { color: var(--rv-accent); margin-bottom: 5px; }
211.coach-body { font-size: 13px; line-height: 1.5; }
212 
213.coach-foot {
214 display: flex;
215 align-items: center;
216 justify-content: space-between;
217 gap: 10px;
218 margin-top: 11px;
219 padding-top: 9px;
220 border-top: 1px solid var(--rv-line);
222.coach-count { font-family: var(--rv-mono); font-size: 11px; }
223.coach-actions { display: flex; align-items: center; gap: 10px; }
224.coach-next { padding: 4px 12px; font-size: 13px; }
225.coach-waiting { font-size: 11px; font-style: italic; }
226.coach-skip {
227 font-size: 11px;
228 color: var(--rv-faint);
229 background: transparent;
230 border: 0;
231 cursor: pointer;
232 padding: 4px 2px;
234.coach-skip:hover { color: var(--rv-fg); }
235.coach-skip:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
236 
237/* This component is scoped, so the central main.css reduced-motion block can't
238 reach it — neutralize its motion here. Position changes snap; no entrance. */
239@media (prefers-reduced-motion: reduce) {
240 .coach-mark, .coach-halo { transition: none; animation: none; }
⋯ 1 line hidden (lines 242–242)
242</style>

Driving the beats from the board

play.vue is the single orchestration site: it reads the game and drives the store one way. A run beginning auto-starts the first beat; tearing the board down calls stop rather than stranding the tour over an empty board. Picking who and when clears the opening beat (the reading beats advance on the card's own Next).

Hydrate + auto-start, the gates, the overstay bow-out, the phone-tab nudge.

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

The mount sits with the other page-level overlays, wrapped in <ClientOnly> since it reads localStorage and measures live rects. The one other template edit adds a data-testid="wager-line" so the wager beat always has a fallback anchor.

Mounted beside EndGameScreen / RunPacksModal, client-only.

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

Replay from the account popover

The "togglable later" half of the ask is one entry in the account popover. Skipping the tour disables it for good (completion is remembered); this brings it back. It's offered only on a live, playing board — the tour anchors to board controls, so on the mission-select briefing there'd be nothing to point at (and the gate-only send beat would strand). Replay closes the popover and re-runs in memory, leaving the persisted verdict intact.

The entry, guarded by canReplayTour.

components/RunsBalance.vue · 125 lines
components/RunsBalance.vue125 lines · Vue
⋯ 41 lines hidden (lines 1–41)
1<template>
2 <!-- The run balance, as a header gauge (◈ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 rv-mono text-sm rv-tap rv-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="rv-faint" aria-hidden="true"></span>
9 <span class="rv-fg font-semibold">{{ display }}</span>
10 <span class="rv-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="rv-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-72 rv-bg border rv-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="rv-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="rv-mono text-2xl font-extrabold rv-fg leading-none">{{ runs }}</span>
19 <span class="rv-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="rv-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs — grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <button type="button" data-testid="account-get-runs" class="rv-btn rv-btn--primary w-full mt-3 text-sm justify-center"
27 @click="getRuns">Get more runs</button>
28 
29 <div class="mt-3 pt-2.5 border-t rv-line">
30 <template v-if="gameStore.accountEmail">
31 <p class="rv-faint text-[11px] leading-relaxed">Signed in as <span class="rv-fg">{{ gameStore.accountEmail }}</span></p>
32 <button type="button" data-testid="account-signout" class="rv-tap rv-hover-fg text-[11px] mt-1 underline" @click="signOut">Sign out</button>
33 </template>
34 <SignInForm v-else-if="gameStore.accountAnonymous"
35 hint="Save your runs to an account — keep them across devices, and unlock buying." />
36 <p v-else-if="gameStore.deviceRef" class="rv-faint text-[10px]">
37 Anonymous play · support id <span class="rv-mono">{{ gameStore.deviceRef }}</span>
38 </p>
39 </div>
40 
41 <!-- Replay the guided first run on demand. Only on a live board, where the
42 tour has controls to point at — skipping it disables it for good
43 (completion is remembered); this brings it back. -->
44 <div v-if="canReplayTour" class="mt-3 pt-2.5 border-t rv-line">
45 <button type="button" data-testid="account-replay-tour" class="rv-tap rv-hover-fg text-[11px] underline"
46 @click="replayTour"><span aria-hidden="true"></span> Replay the guided tour</button>
47 </div>
⋯ 78 lines hidden (lines 48–125)
48 </div>
49 </Transition>
50 </div>
51</template>
52 
53<script setup lang="ts">
54/**
55 * RunsBalance — the persistent header gauge for runs remaining, and the entry to a
56 * lightweight account popover (runs left, a one-line explainer, "get more runs",
57 * and a short support id). Play is anonymous (device-keyed) until real accounts
58 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
59 */
60import { ref, computed, onMounted, onUnmounted } from 'vue'
61import { useGameStore } from '~/stores/game'
62import { useCoachingStore } from '~/stores/coaching'
63 
64const gameStore = useGameStore()
65const coaching = useCoachingStore()
66const open = ref(false)
67const rootRef = ref<HTMLElement | null>(null)
68 
69const runs = computed(() => gameStore.runsRemaining ?? 0)
70// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
71const display = computed(() => (gameStore.runsRemaining == null ? '–' : String(runs.value)))
72const aria = computed(() =>
73 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
75 
76function toggle() { open.value = !open.value }
77function close() { open.value = false }
78 
79function getRuns() {
80 close()
81 void gameStore.openBuyModal()
83 
84// The tour anchors to live board controls, so replay is offered only during a
85// playing run (on the briefing screen there's nothing to point at).
86const canReplayTour = computed(() => !!gameStore.currentObjective && gameStore.gameStatus === 'playing')
87function replayTour() {
88 close()
89 coaching.replay()
91 
92const supabase = useSupabaseClient()
93async function signOut() {
94 await supabase.auth.signOut()
95 // Drop straight back to an anonymous session so play continues uninterrupted.
96 try { await supabase.auth.signInAnonymously() } catch { /* provider off — device fallback */ }
97 await gameStore.loadBalance()
98 close()
100 
101function onDocClick(e: MouseEvent) {
102 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
104function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
105 
106onMounted(() => {
107 document.addEventListener('click', onDocClick)
108 document.addEventListener('keydown', onKeydown)
109})
110onUnmounted(() => {
111 document.removeEventListener('click', onDocClick)
112 document.removeEventListener('keydown', onKeydown)
113})
114</script>
115 
116<style scoped>
117.rv-pop-enter-active,
118.rv-pop-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
119.rv-pop-enter-from,
120.rv-pop-leave-to { opacity: 0; }
121@media (prefers-reduced-motion: reduce) {
122 .rv-pop-enter-active,
123 .rv-pop-leave-active { transition: none; }
125</style>

Live-board guard + the replay call.

components/RunsBalance.vue · 125 lines
components/RunsBalance.vue125 lines · Vue
⋯ 83 lines hidden (lines 1–83)
1<template>
2 <!-- The run balance, as a header gauge (◈ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 rv-mono text-sm rv-tap rv-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="rv-faint" aria-hidden="true"></span>
9 <span class="rv-fg font-semibold">{{ display }}</span>
10 <span class="rv-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="rv-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-72 rv-bg border rv-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="rv-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="rv-mono text-2xl font-extrabold rv-fg leading-none">{{ runs }}</span>
19 <span class="rv-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="rv-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs — grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <button type="button" data-testid="account-get-runs" class="rv-btn rv-btn--primary w-full mt-3 text-sm justify-center"
27 @click="getRuns">Get more runs</button>
28 
29 <div class="mt-3 pt-2.5 border-t rv-line">
30 <template v-if="gameStore.accountEmail">
31 <p class="rv-faint text-[11px] leading-relaxed">Signed in as <span class="rv-fg">{{ gameStore.accountEmail }}</span></p>
32 <button type="button" data-testid="account-signout" class="rv-tap rv-hover-fg text-[11px] mt-1 underline" @click="signOut">Sign out</button>
33 </template>
34 <SignInForm v-else-if="gameStore.accountAnonymous"
35 hint="Save your runs to an account — keep them across devices, and unlock buying." />
36 <p v-else-if="gameStore.deviceRef" class="rv-faint text-[10px]">
37 Anonymous play · support id <span class="rv-mono">{{ gameStore.deviceRef }}</span>
38 </p>
39 </div>
40 
41 <!-- Replay the guided first run on demand. Only on a live board, where the
42 tour has controls to point at — skipping it disables it for good
43 (completion is remembered); this brings it back. -->
44 <div v-if="canReplayTour" class="mt-3 pt-2.5 border-t rv-line">
45 <button type="button" data-testid="account-replay-tour" class="rv-tap rv-hover-fg text-[11px] underline"
46 @click="replayTour"><span aria-hidden="true"></span> Replay the guided tour</button>
47 </div>
48 </div>
49 </Transition>
50 </div>
51</template>
52 
53<script setup lang="ts">
54/**
55 * RunsBalance — the persistent header gauge for runs remaining, and the entry to a
56 * lightweight account popover (runs left, a one-line explainer, "get more runs",
57 * and a short support id). Play is anonymous (device-keyed) until real accounts
58 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
59 */
60import { ref, computed, onMounted, onUnmounted } from 'vue'
61import { useGameStore } from '~/stores/game'
62import { useCoachingStore } from '~/stores/coaching'
63 
64const gameStore = useGameStore()
65const coaching = useCoachingStore()
66const open = ref(false)
67const rootRef = ref<HTMLElement | null>(null)
68 
69const runs = computed(() => gameStore.runsRemaining ?? 0)
70// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
71const display = computed(() => (gameStore.runsRemaining == null ? '–' : String(runs.value)))
72const aria = computed(() =>
73 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
75 
76function toggle() { open.value = !open.value }
77function close() { open.value = false }
78 
79function getRuns() {
80 close()
81 void gameStore.openBuyModal()
83 
84// The tour anchors to live board controls, so replay is offered only during a
85// playing run (on the briefing screen there's nothing to point at).
86const canReplayTour = computed(() => !!gameStore.currentObjective && gameStore.gameStatus === 'playing')
87function replayTour() {
88 close()
89 coaching.replay()
⋯ 35 lines hidden (lines 91–125)
91 
92const supabase = useSupabaseClient()
93async function signOut() {
94 await supabase.auth.signOut()
95 // Drop straight back to an anonymous session so play continues uninterrupted.
96 try { await supabase.auth.signInAnonymously() } catch { /* provider off — device fallback */ }
97 await gameStore.loadBalance()
98 close()
100 
101function onDocClick(e: MouseEvent) {
102 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
104function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
105 
106onMounted(() => {
107 document.addEventListener('click', onDocClick)
108 document.addEventListener('keydown', onKeydown)
109})
110onUnmounted(() => {
111 document.removeEventListener('click', onDocClick)
112 document.removeEventListener('keydown', onKeydown)
113})
114</script>
115 
116<style scoped>
117.rv-pop-enter-active,
118.rv-pop-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
119.rv-pop-enter-from,
120.rv-pop-leave-to { opacity: 0; }
121@media (prefers-reduced-motion: reduce) {
122 .rv-pop-enter-active,
123 .rv-pop-leave-active { transition: none; }
125</style>

How it's verified

The change ships green: typecheck clean, 591 unit tests, 6 Playwright e2e tests, and the six beats were screenshot-verified on desktop and a phone viewport.

The unit tests carry the discriminating logic. The store spec drives the state machine and every persistence path, including the replay-can't-clobber-the-verdict and reset-mid-tour cases. The composable spec stubs target rects (happy-dom has no layout engine) to check first-visible resolution, the fallback order, and the hidden-target path. The component spec asserts render, advance, the gate-only beat, the a11y live region, and — with a stubbed rect and viewport — the flip-to-fit placement.

A replay's skip leaves the original 'completed' on disk.

tests/unit/stores/coaching.spec.ts · 229 lines
tests/unit/stores/coaching.spec.ts229 lines · TypeScript
⋯ 176 lines hidden (lines 1–176)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { setActivePinia, createPinia } from 'pinia'
3import { useCoachingStore, COACH_STEPS } from '../../../stores/coaching'
4 
5const KEY = 'revisionist:coaching:v1'
6 
7// The store guards persistence on `typeof localStorage`, so a tiny in-memory stub
8// is all the node env needs to exercise the real read/write path.
9function stubStorage() {
10 const map = new Map<string, string>()
11 vi.stubGlobal('localStorage', {
12 getItem: (k: string) => map.get(k) ?? null,
13 setItem: (k: string, v: string) => { map.set(k, v) },
14 removeItem: (k: string) => { map.delete(k) },
15 clear: () => { map.clear() }
16 })
17 return map
19 
20describe('coaching store', () => {
21 beforeEach(() => {
22 setActivePinia(createPinia())
23 stubStorage()
24 })
25 afterEach(() => vi.unstubAllGlobals())
26 
27 describe('hydrate', () => {
28 it('starts idle + hydrated when nothing is stored', () => {
29 const c = useCoachingStore()
30 c.hydrate()
31 expect(c.status).toBe('idle')
32 expect(c.hydrated).toBe(true)
33 })
34 
35 it('mirrors a persisted completed/dismissed verdict', () => {
36 localStorage.setItem(KEY, JSON.stringify({ status: 'completed' }))
37 const c = useCoachingStore()
38 c.hydrate()
39 expect(c.status).toBe('completed')
40 })
41 
42 it('ignores a malformed value and treats it as unseen', () => {
43 localStorage.setItem(KEY, 'not json')
44 const c = useCoachingStore()
45 c.hydrate()
46 expect(c.status).toBe('idle')
47 })
48 
49 it('is idempotent — a second call does not re-read', () => {
50 const c = useCoachingStore()
51 c.hydrate()
52 localStorage.setItem(KEY, JSON.stringify({ status: 'dismissed' }))
53 c.hydrate()
54 expect(c.status).toBe('idle')
55 })
56 })
57 
58 describe('maybeAutoStart', () => {
59 const live = { hasObjective: true, isPlaying: true, isFirstTurn: true }
60 
61 it('starts only when hydrated, idle, and a fresh first turn is live', () => {
62 const c = useCoachingStore()
63 c.hydrate()
64 c.maybeAutoStart(live)
65 expect(c.status).toBe('running')
66 expect(c.stepIndex).toBe(0)
67 })
68 
69 it('does nothing before hydrate', () => {
70 const c = useCoachingStore()
71 c.maybeAutoStart(live)
72 expect(c.status).toBe('idle')
73 })
74 
75 it.each([
76 ['no objective', { ...live, hasObjective: false }],
77 ['not playing', { ...live, isPlaying: false }],
78 ['not the first turn', { ...live, isFirstTurn: false }]
79 ])('does not start when %s', (_label, ctx) => {
80 const c = useCoachingStore()
81 c.hydrate()
82 c.maybeAutoStart(ctx)
83 expect(c.status).toBe('idle')
84 })
85 
86 it.each(['completed', 'dismissed'])('never re-fires for a %s player', (status) => {
87 localStorage.setItem(KEY, JSON.stringify({ status }))
88 const c = useCoachingStore()
89 c.hydrate()
90 c.maybeAutoStart(live)
91 expect(c.status).toBe(status)
92 })
93 })
94 
95 describe('advancing', () => {
96 it('walks every step then completes (and persists) past the end', () => {
97 const c = useCoachingStore()
98 c.start()
99 for (let i = 0; i < COACH_STEPS.length - 1; i++) {
100 c.next()
101 expect(c.stepIndex).toBe(i + 1)
102 }
103 expect(c.isLastStep).toBe(true)
104 c.next() // off the end
105 expect(c.status).toBe('completed')
106 expect(JSON.parse(localStorage.getItem(KEY)!).status).toBe('completed')
107 })
108 
109 it('exposes activeStep only while running', () => {
110 const c = useCoachingStore()
111 expect(c.activeStep).toBeNull()
112 c.start()
113 expect(c.activeStep?.id).toBe('who-when')
114 c.skip()
115 expect(c.activeStep).toBeNull()
116 })
117 
118 it('goTo only moves forward', () => {
119 const c = useCoachingStore()
120 c.start()
121 c.goTo(3)
122 expect(c.stepIndex).toBe(3)
123 c.goTo(1) // backward — ignored
124 expect(c.stepIndex).toBe(3)
125 c.goTo(99) // out of range — ignored
126 expect(c.stepIndex).toBe(3)
127 })
128 
129 it('advanceFrom is a no-op unless its step is showing', () => {
130 const c = useCoachingStore()
131 c.start() // on 'who-when'
132 c.advanceFrom('send')
133 expect(c.stepIndex).toBe(0)
134 c.advanceFrom('who-when')
135 expect(c.stepIndex).toBe(1)
136 })
137 
138 it('advanceTo fast-forwards by id, never backward', () => {
139 const c = useCoachingStore()
140 c.start()
141 c.advanceTo('roll-swing')
142 expect(c.activeStep?.id).toBe('roll-swing')
143 c.advanceTo('who-when') // backward — ignored
144 expect(c.activeStep?.id).toBe('roll-swing')
145 })
146 })
147 
148 describe('terminal verdicts + replay', () => {
149 it('skip records dismissed', () => {
150 const c = useCoachingStore()
151 c.start()
152 c.skip()
153 expect(c.status).toBe('dismissed')
154 expect(JSON.parse(localStorage.getItem(KEY)!).status).toBe('dismissed')
155 })
156 
157 it('complete records completed', () => {
158 const c = useCoachingStore()
159 c.start()
160 c.complete()
161 expect(c.status).toBe('completed')
162 expect(JSON.parse(localStorage.getItem(KEY)!).status).toBe('completed')
163 })
164 
165 it('replay re-runs without rewriting the persisted verdict', () => {
166 const c = useCoachingStore()
167 c.start()
168 c.complete()
169 const persistedBefore = localStorage.getItem(KEY)
170 c.replay()
171 expect(c.status).toBe('running')
172 expect(c.stepIndex).toBe(0)
173 // the verdict on disk is untouched, so a mid-replay reload won't auto-start
174 expect(localStorage.getItem(KEY)).toBe(persistedBefore)
175 })
176 
177 it('a replay that is skipped never clobbers the original completed verdict', () => {
178 localStorage.setItem(KEY, JSON.stringify({ status: 'completed' }))
179 const c = useCoachingStore()
180 c.hydrate()
181 c.replay()
182 c.skip() // dismisses the visible replay…
183 expect(c.status).toBe('dismissed')
184 // …but the durable record stays the original 'completed'
185 expect(JSON.parse(localStorage.getItem(KEY)!).status).toBe('completed')
186 })
187 
188 it('a replay that auto-completes never clobbers the original dismissed verdict', () => {
189 localStorage.setItem(KEY, JSON.stringify({ status: 'dismissed' }))
190 const c = useCoachingStore()
191 c.hydrate()
192 c.replay()
193 c.complete()
194 expect(c.status).toBe('completed')
195 expect(JSON.parse(localStorage.getItem(KEY)!).status).toBe('dismissed')
196 })
⋯ 33 lines hidden (lines 197–229)
197 })
198 
199 describe('stop (board torn down mid-tour)', () => {
200 it('returns a brand-new first tour to idle so it can be re-coached', () => {
201 const c = useCoachingStore()
202 c.hydrate()
203 c.start()
204 c.stop()
205 expect(c.status).toBe('idle')
206 // and a fresh first turn would auto-start it again
207 c.maybeAutoStart({ hasObjective: true, isPlaying: true, isFirstTurn: true })
208 expect(c.status).toBe('running')
209 })
210 
211 it('returns a replay to the persisted verdict so it cannot auto-start again', () => {
212 localStorage.setItem(KEY, JSON.stringify({ status: 'completed' }))
213 const c = useCoachingStore()
214 c.hydrate()
215 c.replay()
216 c.stop()
217 expect(c.status).toBe('completed')
218 c.maybeAutoStart({ hasObjective: true, isPlaying: true, isFirstTurn: true })
219 expect(c.status).toBe('completed') // no re-fire
220 })
221 
222 it('is a no-op when no tour is running', () => {
223 const c = useCoachingStore()
224 c.hydrate()
225 c.stop()
226 expect(c.status).toBe('idle')
227 })
228 })
229})

The e2e walkthrough exercises the whole flow against mocked AI routes: a run begins, picking the figure advances the opening beat, the reading beats step on Next, the send beat waits for a real resolved turn, the reveal lands, and Done remembers. Sibling tests cover skip, never-reappear, replay, and the briefing-screen guard.

The end-to-end six-beat walkthrough.

tests/e2e/features/coaching.spec.ts · 113 lines
tests/e2e/features/coaching.spec.ts113 lines · TypeScript
⋯ 23 lines hidden (lines 1–23)
1import { test, expect } from '@nuxt/test-utils/playwright'
2import { startCuratedRun, selectFigure, fillAndSend, mockSendMessage, mockFigure } from '../support/game'
3 
4/**
5 * The guided first run (issue #60): a skippable, non-blocking coach-mark that walks
6 * a brand-new player through the core loop in context, then never reappears. Each
7 * test runs in a fresh browser context (its own device + free run + empty storage),
8 * so one run per test is all the balance allows — and all any of these needs.
9 */
10 
11const KEY = 'revisionist:coaching:v1'
12 
13// A resolved figure so selecting one auto-sets the contact year — the real action
14// that clears the opening "who & when" beat.
15const RESOLVED_CLEO = {
16 resolved: true,
17 name: 'Cleopatra',
18 description: 'Queen of Egypt',
19 born: { signed: -69, display: '69 BC' },
20 died: { signed: -30, display: '30 BC' },
21 living: false
23 
24test('coaches a new player through the first run, then bows out', async ({ page, goto }) => {
25 await mockSendMessage(page, { figureName: 'Cleopatra', diceRoll: 17, diceOutcome: 'Success', progressChange: 24 })
26 await goto('/play', { waitUntil: 'hydration' })
27 await startCuratedRun(page)
28 await mockFigure(page, RESOLVED_CLEO) // last-registered wins over startCuratedRun's default
29 
30 const mark = page.locator('[data-testid="coach-mark"]')
31 await expect(mark).toBeVisible()
32 await expect(mark).toContainText('Who & when')
33 
34 // Beat 1 → 2: the real action (pick who & when) advances it, no Next needed.
35 await selectFigure(page, 'Cleopatra')
36 await expect(mark).toContainText('The dispatch')
37 
38 // Beats 2 → 3 → 4: the reading beats advance on the card's own Next.
39 await page.locator('[data-testid="coach-next"]').click()
40 await expect(mark).toContainText('Read the wager')
41 await page.locator('[data-testid="coach-next"]').click()
42 await expect(mark).toContainText('Send it')
43 
44 // Beat 4 is gate-only: no Next — only an actual resolved turn carries it forward.
45 await expect(page.locator('[data-testid="coach-next"]')).toHaveCount(0)
46 await fillAndSend(page, 'Treat with Rome before war finds you.')
47 
48 // The reveal lands → the roll beat, then the fuse beat, then it's done.
49 await expect(mark).toContainText('The roll & the swing')
50 await page.locator('[data-testid="coach-next"]').click()
51 await expect(mark).toContainText('Five messages')
52 await page.locator('[data-testid="coach-next"]').click() // "Done"
53 await expect(mark).toHaveCount(0)
54 
55 const stored = await page.evaluate((k) => localStorage.getItem(k), KEY)
56 expect(stored).toContain('completed')
57})
⋯ 56 lines hidden (lines 58–113)
58 
59test('never blocks the board — the taught control stays live underneath', async ({ page, goto }) => {
60 await goto('/play', { waitUntil: 'hydration' })
61 await startCuratedRun(page)
62 const mark = page.locator('[data-testid="coach-mark"]')
63 await expect(mark).toContainText('Who & when')
64 // The figure input (the very control beat 1 points at) is clickable through the
65 // overlay — proof there's no scrim or focus trap intercepting pointer events.
66 await page.locator('[data-testid="figure-input"]').click()
67 await expect(page.locator('[data-testid="figure-input"]')).toBeFocused()
68})
69 
70test('Skip tour dismisses it and remembers', async ({ page, goto }) => {
71 await goto('/play', { waitUntil: 'hydration' })
72 await startCuratedRun(page)
73 const mark = page.locator('[data-testid="coach-mark"]')
74 await expect(mark).toBeVisible()
75 await page.locator('[data-testid="coach-skip"]').click()
76 await expect(mark).toHaveCount(0)
77 const stored = await page.evaluate((k) => localStorage.getItem(k), KEY)
78 expect(stored).toContain('dismissed')
79})
80 
81test('a returning player who already finished sees no tour', async ({ page, goto }) => {
82 // Seed the verdict BEFORE any page script runs, so hydrate reads it on mount.
83 await page.addInitScript((k) => {
84 localStorage.setItem(k, JSON.stringify({ status: 'completed' }))
85 }, KEY)
86 await goto('/play', { waitUntil: 'hydration' })
87 await startCuratedRun(page)
88 await expect(page.locator('[data-testid="coach-mark"]')).toHaveCount(0)
89})
90 
91test('the account popover can replay the tour on demand', async ({ page, goto }) => {
92 await page.addInitScript((k) => {
93 localStorage.setItem(k, JSON.stringify({ status: 'completed' }))
94 }, KEY)
95 await goto('/play', { waitUntil: 'hydration' })
96 await startCuratedRun(page)
97 const mark = page.locator('[data-testid="coach-mark"]')
98 await expect(mark).toHaveCount(0) // no auto-start for a finished player
99 
100 await page.locator('[data-testid="runs-balance-toggle"]').click()
101 await page.locator('[data-testid="account-replay-tour"]').click()
102 await expect(mark).toBeVisible()
103 await expect(mark).toContainText('Who & when')
104})
105 
106test('replay is offered only on a live board, not the briefing screen', async ({ page, goto }) => {
107 await goto('/play', { waitUntil: 'hydration' })
108 // The mission-select briefing has no board to coach, so replay is hidden there
109 // (it would otherwise strand on the gate-only "send" beat with nothing to send).
110 await page.locator('[data-testid="runs-balance-toggle"]').click()
111 await expect(page.locator('[data-testid="account-popover"]')).toBeVisible()
112 await expect(page.locator('[data-testid="account-replay-tour"]')).toHaveCount(0)
113})