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.
| File | Role |
|---|---|
stores/coaching.ts | the six-beat state machine, copy, and the one persisted flag |
composables/useCoachAnchor.ts | resolve a control to its live viewport rect, tracked over scroll/resize/layout |
components/CoachMark.vue | the floating card + halo; the non-blocking overlay |
pages/play.vue | mounts it; drives the beats from game signals |
components/RunsBalance.vue | the 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
⋯ 39 lines hidden (lines 1–39)
⋯ 148 lines hidden (lines 95–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
⋯ 95 lines hidden (lines 1–95)
⋯ 112 lines hidden (lines 131–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
⋯ 163 lines hidden (lines 1–163)
⋯ 36 lines hidden (lines 207–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
⋯ 207 lines hidden (lines 1–207)
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
⋯ 33 lines hidden (lines 1–33)
⋯ 52 lines hidden (lines 80–131)
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
⋯ 81 lines hidden (lines 1–81)
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
⋯ 214 lines hidden (lines 29–242)
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
⋯ 101 lines hidden (lines 1–101)
⋯ 101 lines hidden (lines 142–242)
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
⋯ 159 lines hidden (lines 1–159)
⋯ 74 lines hidden (lines 164–237)
⋯ 1 line hidden (lines 242–242)
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
⋯ 470 lines hidden (lines 1–470)
⋯ 35 lines hidden (lines 520–554)
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
⋯ 253 lines hidden (lines 1–253)
⋯ 297 lines hidden (lines 258–554)
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
⋯ 41 lines hidden (lines 1–41)
⋯ 78 lines hidden (lines 48–125)
Live-board guard + the replay call.
components/RunsBalance.vue · 125 lines
⋯ 83 lines hidden (lines 1–83)
⋯ 35 lines hidden (lines 91–125)
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
⋯ 176 lines hidden (lines 1–176)
⋯ 33 lines hidden (lines 197–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.