What changed, and why
A resolved turn applied all its state in one synchronous block — the AI reply, the ledger node, the progress swing — and flipped loading off. Every component watches its own slice and animates on change, so they all fired in the same tick: the die landing, the reply, the %-swing, and the spine node moved at once, with no suspense and no path for the eye.
The fix doesn't touch any component. It sequences the writes in the store, which sequences the existing animations for free — into one conducted beat.
The pacing, and when it applies
Three small constants and a guard. canAnimateReveal() is the seam: only a real browser with motion allowed staggers the reveal. SSR and the test env have no matchMedia, so they collapse to the instant, atomic apply — which is why the store's contract (all state present the moment sendMessage resolves) is unchanged and every other test is unaffected.
REVEAL timings + the matchMedia gate that keeps SSR/tests instant.
stores/game.ts · 1249 lines
⋯ 149 lines hidden (lines 1–149)
⋯ 1077 lines hidden (lines 173–1249)
The conducted sequence
Inside sendMessage, after the response: a beat of suspense (the die keeps casting), then the reply lands and loading is flipped here (mid-sequence) so the die lands as its own beat; then the swing, timed to coincide with the reply's own staged "ripple" line; then the ledger node. Each await is epoch-guarded, so a reset mid-reveal returns without writing into the new run.
suspense → die+reply → swing → node, each gated by animate and the epoch.
stores/game.ts · 1249 lines
⋯ 931 lines hidden (lines 1–931)
⋯ 259 lines hidden (lines 991–1249)
Tests
The cadence is asserted deterministically with fake timers + a stubbed matchMedia: step the clock and check each beat lands in order (suspense → reply → swing → node), and that a reset mid-reveal aborts without leaking state into the new run. The instant path stays covered by every existing sendMessage test.
Advance timers beat-by-beat; assert the ordering and the mid-reveal abort.