What changed, and why

The momentum meter already had a lot of feedback for a reset: the pips shatter, and a screen-reader announcement fires ("Momentum lost — the arc broke."). But the visible hint under the meter never said why the arc broke. A player who glanced away saw the pips empty with no cause. Worse, under prefers-reduced-motion the shatter is muted, so that path got the least signal of all.

The fix is small and contained to one component. On any momentum drop, the hint briefly shows a cause line — "Arc broken — a loss reset your momentum" — then settles back to its normal coaching text after 2.6s. Nothing else moves: it reuses the existing hint slot and the existing live region, adds two component tests, and touches no game logic. Blast radius is one file's <script setup>.

Where the hint and the ARIA line render. Both already existed — only the hint's content gains a transient cause line.

components/MomentumMeter.vue · 149 lines
components/MomentumMeter.vue149 lines · Vue
⋯ 14 lines hidden (lines 1–14)
1<template>
2 <div data-testid="momentum-meter" :data-momentum="gameStore.momentum">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="rv-label">Momentum</span>
5 <span data-testid="momentum-factor" class="rv-mono text-xs" :class="momentum > 0 ? 'rv-ok font-semibold' : 'rv-faint'">
6 ×{{ factor.toFixed(1) }}<span class="rv-faint"> to gains</span>
7 </span>
8 </div>
9 
10 <div class="momentum-pips" :class="{ shattering }">
11 <span v-for="i in MOMENTUM_MAX" :key="i" :data-testid="`momentum-pip-${i}`"
12 class="momentum-pip" :class="{ filled: i <= momentum }" aria-hidden="true" />
13 </div>
14 
15 <p class="text-[11px] rv-faint mt-1">{{ hint }}</p>
16 
17 <!-- Each arc shift, spoken to assistive tech (the pips + factor are visual only). -->
18 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
⋯ 131 lines hidden (lines 19–149)
19 </div>
20</template>
21 
22<script setup lang="ts">
23/**
24 * MomentumMeter — the run's ARC gauge (issue #62). Per-turn craft is re-rolled
25 * every turn; momentum is the PERSISTENT skill-state the dice can't randomize away.
26 * A coherent arc — building on a figure's revealed conditions, exploiting your own
27 * prior changes — fills the meter and compounds a gains multiplier (shown as
28 * "×N.N to gains"); a reset or a catastrophe shatters it back to empty. The visible
29 * reward surface is the whole point: the player WATCHES their plan compound.
30 */
31import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
32import { useGameStore } from '~/stores/game'
33import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
34import { MOMENTUM_MAX, momentumFactor } from '~/utils/momentum'
35 
36const gameStore = useGameStore()
37const reducedMotion = usePrefersReducedMotion()
38 
39const momentum = computed(() => gameStore.momentum)
40const factor = computed(() => momentumFactor(momentum.value))
41 
42const shattering = ref(false)
43const liveMessage = ref('')
44// A transient 'why it broke' line, overlaid on the hint when the arc resets — the
45// shatter is mute on CAUSE, so a glancing or reduced-motion player learns nothing.
46const resetNotice = ref('')
47let shatterTimer: ReturnType<typeof setTimeout> | null = null
48let noticeTimer: ReturnType<typeof setTimeout> | null = null
49 
50/** How long the reset cause lingers before the hint settles back. Comfortably longer
51 * than the 620ms shatter — this is text to READ, not a flash to glimpse. */
52const RESET_NOTICE_MS = 2600
53 
54const baseHint = computed(() =>
55 momentum.value === 0
56 ? 'Build on the thread to gather momentum'
57 : momentum.value >= MOMENTUM_MAX
58 ? 'Peak momentum — every gain amplified'
59 : 'A coherent arc amplifies every gain'
61 
62// While a reset is fresh, the hint names WHY the arc broke; then it settles back.
63const hint = computed(() => resetNotice.value || baseHint.value)
64 
65// A drop in the meter is the arc breaking — name the cause in the hint, flash +
66// shatter the pips (unless reduced motion), and announce it to assistive tech. A
67// climb just announces the new amplified factor.
68watch(() => gameStore.momentum, (now, was) => {
69 if (now < was) {
70 const broke = now === 0
71 liveMessage.value = broke ? 'Momentum lost — the arc broke.' : `Momentum fell to ${now}.`
72 // Surface the CAUSE visibly too, BEFORE the reduced-motion return below — that
73 // path mutes the shatter, so this line is all a reduced-motion player gets.
74 resetNotice.value = broke ? 'Arc broken — a loss reset your momentum' : 'A loss cost you momentum'
75 if (noticeTimer) clearTimeout(noticeTimer)
76 noticeTimer = setTimeout(() => { resetNotice.value = '' }, RESET_NOTICE_MS)
77 // Clear any in-flight shatter first, so a reduced-motion early-return can't
78 // leave a prior animation visually stuck.
79 shattering.value = false
80 if (shatterTimer) clearTimeout(shatterTimer)
81 if (reducedMotion.value) return
82 void nextTick(() => {
83 shattering.value = true
84 shatterTimer = setTimeout(() => { shattering.value = false }, 620)
85 })
86 } else if (now > was) {
87 liveMessage.value = `Momentum ${now} of ${MOMENTUM_MAX} — gains ×${factor.value.toFixed(1)}.`
88 // A fresh climb supersedes a lingering reset notice.
89 if (noticeTimer) clearTimeout(noticeTimer)
90 resetNotice.value = ''
91 }
92})
93 
94onUnmounted(() => {
95 if (shatterTimer) clearTimeout(shatterTimer)
96 if (noticeTimer) clearTimeout(noticeTimer)
97})
98</script>
99 
100<style scoped>
101.momentum-pips {
102 display: grid;
103 grid-template-columns: repeat(4, 1fr);
104 gap: 0.35rem;
106.momentum-pip {
107 height: 0.5rem;
108 border-radius: 0.3rem;
109 border: 1px solid var(--rv-line);
110 background: var(--rv-tint);
111 transition: background-color var(--rv-dur-2) var(--rv-ease-spring),
112 border-color var(--rv-dur-2) var(--rv-ease), box-shadow var(--rv-dur-2) var(--rv-ease);
114.momentum-pip.filled {
115 background: var(--rv-accent);
116 border-color: var(--rv-accent);
117 box-shadow: 0 0 8px color-mix(in srgb, var(--rv-accent) 45%, transparent);
118 animation: pip-fill var(--rv-dur-2) var(--rv-ease-spring);
120@keyframes pip-fill {
121 0% { transform: scaleY(.4); opacity: .5; }
122 60% { transform: scaleY(1.25); }
123 100% { transform: scaleY(1); opacity: 1; }
125 
126/* The shatter — when the arc breaks, the pips flash oxblood and jolt before falling
127 empty. The reduced-motion block below kills it (instant empty, no shards). */
128.momentum-pips.shattering .momentum-pip {
129 animation: pip-shatter .6s var(--rv-ease) both;
131@keyframes pip-shatter {
132 0% {
133 background: var(--rv-bad); border-color: var(--rv-bad);
134 box-shadow: 0 0 10px color-mix(in srgb, var(--rv-bad) 60%, transparent);
135 transform: translateY(0) rotate(0);
136 }
137 30% { transform: translateY(-2px) rotate(-3deg); }
138 60% { transform: translateY(1px) rotate(2deg); opacity: .55; }
139 100% {
140 background: var(--rv-tint); border-color: var(--rv-line);
141 box-shadow: none; transform: translateY(0); opacity: 1;
142 }
144 
145@media (prefers-reduced-motion: reduce) {
146 .momentum-pip { animation: none !important; transition: none !important; }
147 .momentum-pips.shattering .momentum-pip { animation: none !important; }
149</style>

A transient notice, overlaid on the existing hint

The hint was a pure function of the current momentum value, so it could never reflect an event like a break. The change splits it in two: baseHint is the old steady-state text, and a new resetNotice ref holds the transient cause line. The displayed hint is just resetNotice || baseHint — when the notice is set it wins; when it clears, the normal hint returns. A RESET_NOTICE_MS constant (2.6s) names how long it lingers, deliberately longer than the 620ms shatter because this is text to read, not a flash to glimpse.

New reactive state, the reading-time constant, and the one-line overlay.

components/MomentumMeter.vue · 149 lines
components/MomentumMeter.vue149 lines · Vue
⋯ 41 lines hidden (lines 1–41)
1<template>
2 <div data-testid="momentum-meter" :data-momentum="gameStore.momentum">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="rv-label">Momentum</span>
5 <span data-testid="momentum-factor" class="rv-mono text-xs" :class="momentum > 0 ? 'rv-ok font-semibold' : 'rv-faint'">
6 ×{{ factor.toFixed(1) }}<span class="rv-faint"> to gains</span>
7 </span>
8 </div>
9 
10 <div class="momentum-pips" :class="{ shattering }">
11 <span v-for="i in MOMENTUM_MAX" :key="i" :data-testid="`momentum-pip-${i}`"
12 class="momentum-pip" :class="{ filled: i <= momentum }" aria-hidden="true" />
13 </div>
14 
15 <p class="text-[11px] rv-faint mt-1">{{ hint }}</p>
16 
17 <!-- Each arc shift, spoken to assistive tech (the pips + factor are visual only). -->
18 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
19 </div>
20</template>
21 
22<script setup lang="ts">
23/**
24 * MomentumMeter — the run's ARC gauge (issue #62). Per-turn craft is re-rolled
25 * every turn; momentum is the PERSISTENT skill-state the dice can't randomize away.
26 * A coherent arc — building on a figure's revealed conditions, exploiting your own
27 * prior changes — fills the meter and compounds a gains multiplier (shown as
28 * "×N.N to gains"); a reset or a catastrophe shatters it back to empty. The visible
29 * reward surface is the whole point: the player WATCHES their plan compound.
30 */
31import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
32import { useGameStore } from '~/stores/game'
33import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
34import { MOMENTUM_MAX, momentumFactor } from '~/utils/momentum'
35 
36const gameStore = useGameStore()
37const reducedMotion = usePrefersReducedMotion()
38 
39const momentum = computed(() => gameStore.momentum)
40const factor = computed(() => momentumFactor(momentum.value))
41 
42const shattering = ref(false)
43const liveMessage = ref('')
44// A transient 'why it broke' line, overlaid on the hint when the arc resets — the
45// shatter is mute on CAUSE, so a glancing or reduced-motion player learns nothing.
46const resetNotice = ref('')
47let shatterTimer: ReturnType<typeof setTimeout> | null = null
48let noticeTimer: ReturnType<typeof setTimeout> | null = null
49 
50/** How long the reset cause lingers before the hint settles back. Comfortably longer
51 * than the 620ms shatter — this is text to READ, not a flash to glimpse. */
52const RESET_NOTICE_MS = 2600
53 
54const baseHint = computed(() =>
55 momentum.value === 0
56 ? 'Build on the thread to gather momentum'
57 : momentum.value >= MOMENTUM_MAX
58 ? 'Peak momentum — every gain amplified'
59 : 'A coherent arc amplifies every gain'
61 
62// While a reset is fresh, the hint names WHY the arc broke; then it settles back.
63const hint = computed(() => resetNotice.value || baseHint.value)
⋯ 86 lines hidden (lines 64–149)
64 
65// A drop in the meter is the arc breaking — name the cause in the hint, flash +
66// shatter the pips (unless reduced motion), and announce it to assistive tech. A
67// climb just announces the new amplified factor.
68watch(() => gameStore.momentum, (now, was) => {
69 if (now < was) {
70 const broke = now === 0
71 liveMessage.value = broke ? 'Momentum lost — the arc broke.' : `Momentum fell to ${now}.`
72 // Surface the CAUSE visibly too, BEFORE the reduced-motion return below — that
73 // path mutes the shatter, so this line is all a reduced-motion player gets.
74 resetNotice.value = broke ? 'Arc broken — a loss reset your momentum' : 'A loss cost you momentum'
75 if (noticeTimer) clearTimeout(noticeTimer)
76 noticeTimer = setTimeout(() => { resetNotice.value = '' }, RESET_NOTICE_MS)
77 // Clear any in-flight shatter first, so a reduced-motion early-return can't
78 // leave a prior animation visually stuck.
79 shattering.value = false
80 if (shatterTimer) clearTimeout(shatterTimer)
81 if (reducedMotion.value) return
82 void nextTick(() => {
83 shattering.value = true
84 shatterTimer = setTimeout(() => { shattering.value = false }, 620)
85 })
86 } else if (now > was) {
87 liveMessage.value = `Momentum ${now} of ${MOMENTUM_MAX} — gains ×${factor.value.toFixed(1)}.`
88 // A fresh climb supersedes a lingering reset notice.
89 if (noticeTimer) clearTimeout(noticeTimer)
90 resetNotice.value = ''
91 }
92})
93 
94onUnmounted(() => {
95 if (shatterTimer) clearTimeout(shatterTimer)
96 if (noticeTimer) clearTimeout(noticeTimer)
97})
98</script>
99 
100<style scoped>
101.momentum-pips {
102 display: grid;
103 grid-template-columns: repeat(4, 1fr);
104 gap: 0.35rem;
106.momentum-pip {
107 height: 0.5rem;
108 border-radius: 0.3rem;
109 border: 1px solid var(--rv-line);
110 background: var(--rv-tint);
111 transition: background-color var(--rv-dur-2) var(--rv-ease-spring),
112 border-color var(--rv-dur-2) var(--rv-ease), box-shadow var(--rv-dur-2) var(--rv-ease);
114.momentum-pip.filled {
115 background: var(--rv-accent);
116 border-color: var(--rv-accent);
117 box-shadow: 0 0 8px color-mix(in srgb, var(--rv-accent) 45%, transparent);
118 animation: pip-fill var(--rv-dur-2) var(--rv-ease-spring);
120@keyframes pip-fill {
121 0% { transform: scaleY(.4); opacity: .5; }
122 60% { transform: scaleY(1.25); }
123 100% { transform: scaleY(1); opacity: 1; }
125 
126/* The shatter — when the arc breaks, the pips flash oxblood and jolt before falling
127 empty. The reduced-motion block below kills it (instant empty, no shards). */
128.momentum-pips.shattering .momentum-pip {
129 animation: pip-shatter .6s var(--rv-ease) both;
131@keyframes pip-shatter {
132 0% {
133 background: var(--rv-bad); border-color: var(--rv-bad);
134 box-shadow: 0 0 10px color-mix(in srgb, var(--rv-bad) 60%, transparent);
135 transform: translateY(0) rotate(0);
136 }
137 30% { transform: translateY(-2px) rotate(-3deg); }
138 60% { transform: translateY(1px) rotate(2deg); opacity: .55; }
139 100% {
140 background: var(--rv-tint); border-color: var(--rv-line);
141 box-shadow: none; transform: translateY(0); opacity: 1;
142 }
144 
145@media (prefers-reduced-motion: reduce) {
146 .momentum-pip { animation: none !important; transition: none !important; }
147 .momentum-pips.shattering .momentum-pip { animation: none !important; }
149</style>

Name the cause on a drop — clear it on a climb

The reset already lived in a watch on momentum. The drop branch gains three lines: set resetNotice to a cause line (the full reset vs. a partial dip), then schedule it to clear after RESET_NOTICE_MS. The climb branch gains the mirror — a recovery supersedes a lingering notice at once, so you never see "a loss reset you" while you're already climbing back.

The drop sets and schedules the notice; the climb clears it.

components/MomentumMeter.vue · 149 lines
components/MomentumMeter.vue149 lines · Vue
⋯ 64 lines hidden (lines 1–64)
1<template>
2 <div data-testid="momentum-meter" :data-momentum="gameStore.momentum">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="rv-label">Momentum</span>
5 <span data-testid="momentum-factor" class="rv-mono text-xs" :class="momentum > 0 ? 'rv-ok font-semibold' : 'rv-faint'">
6 ×{{ factor.toFixed(1) }}<span class="rv-faint"> to gains</span>
7 </span>
8 </div>
9 
10 <div class="momentum-pips" :class="{ shattering }">
11 <span v-for="i in MOMENTUM_MAX" :key="i" :data-testid="`momentum-pip-${i}`"
12 class="momentum-pip" :class="{ filled: i <= momentum }" aria-hidden="true" />
13 </div>
14 
15 <p class="text-[11px] rv-faint mt-1">{{ hint }}</p>
16 
17 <!-- Each arc shift, spoken to assistive tech (the pips + factor are visual only). -->
18 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
19 </div>
20</template>
21 
22<script setup lang="ts">
23/**
24 * MomentumMeter — the run's ARC gauge (issue #62). Per-turn craft is re-rolled
25 * every turn; momentum is the PERSISTENT skill-state the dice can't randomize away.
26 * A coherent arc — building on a figure's revealed conditions, exploiting your own
27 * prior changes — fills the meter and compounds a gains multiplier (shown as
28 * "×N.N to gains"); a reset or a catastrophe shatters it back to empty. The visible
29 * reward surface is the whole point: the player WATCHES their plan compound.
30 */
31import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
32import { useGameStore } from '~/stores/game'
33import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
34import { MOMENTUM_MAX, momentumFactor } from '~/utils/momentum'
35 
36const gameStore = useGameStore()
37const reducedMotion = usePrefersReducedMotion()
38 
39const momentum = computed(() => gameStore.momentum)
40const factor = computed(() => momentumFactor(momentum.value))
41 
42const shattering = ref(false)
43const liveMessage = ref('')
44// A transient 'why it broke' line, overlaid on the hint when the arc resets — the
45// shatter is mute on CAUSE, so a glancing or reduced-motion player learns nothing.
46const resetNotice = ref('')
47let shatterTimer: ReturnType<typeof setTimeout> | null = null
48let noticeTimer: ReturnType<typeof setTimeout> | null = null
49 
50/** How long the reset cause lingers before the hint settles back. Comfortably longer
51 * than the 620ms shatter — this is text to READ, not a flash to glimpse. */
52const RESET_NOTICE_MS = 2600
53 
54const baseHint = computed(() =>
55 momentum.value === 0
56 ? 'Build on the thread to gather momentum'
57 : momentum.value >= MOMENTUM_MAX
58 ? 'Peak momentum — every gain amplified'
59 : 'A coherent arc amplifies every gain'
61 
62// While a reset is fresh, the hint names WHY the arc broke; then it settles back.
63const hint = computed(() => resetNotice.value || baseHint.value)
64 
65// A drop in the meter is the arc breaking — name the cause in the hint, flash +
66// shatter the pips (unless reduced motion), and announce it to assistive tech. A
67// climb just announces the new amplified factor.
68watch(() => gameStore.momentum, (now, was) => {
69 if (now < was) {
70 const broke = now === 0
71 liveMessage.value = broke ? 'Momentum lost — the arc broke.' : `Momentum fell to ${now}.`
72 // Surface the CAUSE visibly too, BEFORE the reduced-motion return below — that
73 // path mutes the shatter, so this line is all a reduced-motion player gets.
74 resetNotice.value = broke ? 'Arc broken — a loss reset your momentum' : 'A loss cost you momentum'
75 if (noticeTimer) clearTimeout(noticeTimer)
76 noticeTimer = setTimeout(() => { resetNotice.value = '' }, RESET_NOTICE_MS)
77 // Clear any in-flight shatter first, so a reduced-motion early-return can't
78 // leave a prior animation visually stuck.
79 shattering.value = false
80 if (shatterTimer) clearTimeout(shatterTimer)
81 if (reducedMotion.value) return
82 void nextTick(() => {
83 shattering.value = true
84 shatterTimer = setTimeout(() => { shattering.value = false }, 620)
85 })
86 } else if (now > was) {
87 liveMessage.value = `Momentum ${now} of ${MOMENTUM_MAX} — gains ×${factor.value.toFixed(1)}.`
88 // A fresh climb supersedes a lingering reset notice.
89 if (noticeTimer) clearTimeout(noticeTimer)
90 resetNotice.value = ''
91 }
92})
⋯ 57 lines hidden (lines 93–149)
93 
94onUnmounted(() => {
95 if (shatterTimer) clearTimeout(shatterTimer)
96 if (noticeTimer) clearTimeout(noticeTimer)
97})
98</script>
99 
100<style scoped>
101.momentum-pips {
102 display: grid;
103 grid-template-columns: repeat(4, 1fr);
104 gap: 0.35rem;
106.momentum-pip {
107 height: 0.5rem;
108 border-radius: 0.3rem;
109 border: 1px solid var(--rv-line);
110 background: var(--rv-tint);
111 transition: background-color var(--rv-dur-2) var(--rv-ease-spring),
112 border-color var(--rv-dur-2) var(--rv-ease), box-shadow var(--rv-dur-2) var(--rv-ease);
114.momentum-pip.filled {
115 background: var(--rv-accent);
116 border-color: var(--rv-accent);
117 box-shadow: 0 0 8px color-mix(in srgb, var(--rv-accent) 45%, transparent);
118 animation: pip-fill var(--rv-dur-2) var(--rv-ease-spring);
120@keyframes pip-fill {
121 0% { transform: scaleY(.4); opacity: .5; }
122 60% { transform: scaleY(1.25); }
123 100% { transform: scaleY(1); opacity: 1; }
125 
126/* The shatter — when the arc breaks, the pips flash oxblood and jolt before falling
127 empty. The reduced-motion block below kills it (instant empty, no shards). */
128.momentum-pips.shattering .momentum-pip {
129 animation: pip-shatter .6s var(--rv-ease) both;
131@keyframes pip-shatter {
132 0% {
133 background: var(--rv-bad); border-color: var(--rv-bad);
134 box-shadow: 0 0 10px color-mix(in srgb, var(--rv-bad) 60%, transparent);
135 transform: translateY(0) rotate(0);
136 }
137 30% { transform: translateY(-2px) rotate(-3deg); }
138 60% { transform: translateY(1px) rotate(2deg); opacity: .55; }
139 100% {
140 background: var(--rv-tint); border-color: var(--rv-line);
141 box-shadow: none; transform: translateY(0); opacity: 1;
142 }
144 
145@media (prefers-reduced-motion: reduce) {
146 .momentum-pip { animation: none !important; transition: none !important; }
147 .momentum-pips.shattering .momentum-pip { animation: none !important; }
149</style>

The new timer joins the shatter timer in unmount cleanup — no set-after-unmount, no leak.

components/MomentumMeter.vue · 149 lines
components/MomentumMeter.vue149 lines · Vue
⋯ 93 lines hidden (lines 1–93)
1<template>
2 <div data-testid="momentum-meter" :data-momentum="gameStore.momentum">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="rv-label">Momentum</span>
5 <span data-testid="momentum-factor" class="rv-mono text-xs" :class="momentum > 0 ? 'rv-ok font-semibold' : 'rv-faint'">
6 ×{{ factor.toFixed(1) }}<span class="rv-faint"> to gains</span>
7 </span>
8 </div>
9 
10 <div class="momentum-pips" :class="{ shattering }">
11 <span v-for="i in MOMENTUM_MAX" :key="i" :data-testid="`momentum-pip-${i}`"
12 class="momentum-pip" :class="{ filled: i <= momentum }" aria-hidden="true" />
13 </div>
14 
15 <p class="text-[11px] rv-faint mt-1">{{ hint }}</p>
16 
17 <!-- Each arc shift, spoken to assistive tech (the pips + factor are visual only). -->
18 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
19 </div>
20</template>
21 
22<script setup lang="ts">
23/**
24 * MomentumMeter — the run's ARC gauge (issue #62). Per-turn craft is re-rolled
25 * every turn; momentum is the PERSISTENT skill-state the dice can't randomize away.
26 * A coherent arc — building on a figure's revealed conditions, exploiting your own
27 * prior changes — fills the meter and compounds a gains multiplier (shown as
28 * "×N.N to gains"); a reset or a catastrophe shatters it back to empty. The visible
29 * reward surface is the whole point: the player WATCHES their plan compound.
30 */
31import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
32import { useGameStore } from '~/stores/game'
33import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
34import { MOMENTUM_MAX, momentumFactor } from '~/utils/momentum'
35 
36const gameStore = useGameStore()
37const reducedMotion = usePrefersReducedMotion()
38 
39const momentum = computed(() => gameStore.momentum)
40const factor = computed(() => momentumFactor(momentum.value))
41 
42const shattering = ref(false)
43const liveMessage = ref('')
44// A transient 'why it broke' line, overlaid on the hint when the arc resets — the
45// shatter is mute on CAUSE, so a glancing or reduced-motion player learns nothing.
46const resetNotice = ref('')
47let shatterTimer: ReturnType<typeof setTimeout> | null = null
48let noticeTimer: ReturnType<typeof setTimeout> | null = null
49 
50/** How long the reset cause lingers before the hint settles back. Comfortably longer
51 * than the 620ms shatter — this is text to READ, not a flash to glimpse. */
52const RESET_NOTICE_MS = 2600
53 
54const baseHint = computed(() =>
55 momentum.value === 0
56 ? 'Build on the thread to gather momentum'
57 : momentum.value >= MOMENTUM_MAX
58 ? 'Peak momentum — every gain amplified'
59 : 'A coherent arc amplifies every gain'
61 
62// While a reset is fresh, the hint names WHY the arc broke; then it settles back.
63const hint = computed(() => resetNotice.value || baseHint.value)
64 
65// A drop in the meter is the arc breaking — name the cause in the hint, flash +
66// shatter the pips (unless reduced motion), and announce it to assistive tech. A
67// climb just announces the new amplified factor.
68watch(() => gameStore.momentum, (now, was) => {
69 if (now < was) {
70 const broke = now === 0
71 liveMessage.value = broke ? 'Momentum lost — the arc broke.' : `Momentum fell to ${now}.`
72 // Surface the CAUSE visibly too, BEFORE the reduced-motion return below — that
73 // path mutes the shatter, so this line is all a reduced-motion player gets.
74 resetNotice.value = broke ? 'Arc broken — a loss reset your momentum' : 'A loss cost you momentum'
75 if (noticeTimer) clearTimeout(noticeTimer)
76 noticeTimer = setTimeout(() => { resetNotice.value = '' }, RESET_NOTICE_MS)
77 // Clear any in-flight shatter first, so a reduced-motion early-return can't
78 // leave a prior animation visually stuck.
79 shattering.value = false
80 if (shatterTimer) clearTimeout(shatterTimer)
81 if (reducedMotion.value) return
82 void nextTick(() => {
83 shattering.value = true
84 shatterTimer = setTimeout(() => { shattering.value = false }, 620)
85 })
86 } else if (now > was) {
87 liveMessage.value = `Momentum ${now} of ${MOMENTUM_MAX} — gains ×${factor.value.toFixed(1)}.`
88 // A fresh climb supersedes a lingering reset notice.
89 if (noticeTimer) clearTimeout(noticeTimer)
90 resetNotice.value = ''
91 }
92})
93 
94onUnmounted(() => {
95 if (shatterTimer) clearTimeout(shatterTimer)
96 if (noticeTimer) clearTimeout(noticeTimer)
97})
⋯ 52 lines hidden (lines 98–149)
98</script>
99 
100<style scoped>
101.momentum-pips {
102 display: grid;
103 grid-template-columns: repeat(4, 1fr);
104 gap: 0.35rem;
106.momentum-pip {
107 height: 0.5rem;
108 border-radius: 0.3rem;
109 border: 1px solid var(--rv-line);
110 background: var(--rv-tint);
111 transition: background-color var(--rv-dur-2) var(--rv-ease-spring),
112 border-color var(--rv-dur-2) var(--rv-ease), box-shadow var(--rv-dur-2) var(--rv-ease);
114.momentum-pip.filled {
115 background: var(--rv-accent);
116 border-color: var(--rv-accent);
117 box-shadow: 0 0 8px color-mix(in srgb, var(--rv-accent) 45%, transparent);
118 animation: pip-fill var(--rv-dur-2) var(--rv-ease-spring);
120@keyframes pip-fill {
121 0% { transform: scaleY(.4); opacity: .5; }
122 60% { transform: scaleY(1.25); }
123 100% { transform: scaleY(1); opacity: 1; }
125 
126/* The shatter — when the arc breaks, the pips flash oxblood and jolt before falling
127 empty. The reduced-motion block below kills it (instant empty, no shards). */
128.momentum-pips.shattering .momentum-pip {
129 animation: pip-shatter .6s var(--rv-ease) both;
131@keyframes pip-shatter {
132 0% {
133 background: var(--rv-bad); border-color: var(--rv-bad);
134 box-shadow: 0 0 10px color-mix(in srgb, var(--rv-bad) 60%, transparent);
135 transform: translateY(0) rotate(0);
136 }
137 30% { transform: translateY(-2px) rotate(-3deg); }
138 60% { transform: translateY(1px) rotate(2deg); opacity: .55; }
139 100% {
140 background: var(--rv-tint); border-color: var(--rv-line);
141 box-shadow: none; transform: translateY(0); opacity: 1;
142 }
144 
145@media (prefers-reduced-motion: reduce) {
146 .momentum-pip { animation: none !important; transition: none !important; }
147 .momentum-pips.shattering .momentum-pip { animation: none !important; }
149</style>

Tests pin the behavior

Two component tests lock in the rendered text at each phase — the discriminating checks that only this layer could catch. The first drives a drop and asserts the hint names the cause, the live region still announces the break, then (via fake timers) that the hint settles back to the zero-state line. The second asserts a climb clears a lingering notice immediately. Full unit suite stays green (901 tests); typecheck is clean.

Cause-then-settle, and climb-supersedes.

tests/unit/components/MomentumMeter.spec.ts · 92 lines
tests/unit/components/MomentumMeter.spec.ts92 lines · TypeScript
⋯ 45 lines hidden (lines 1–45)
1import { describe, it, expect, beforeEach, vi } from 'vitest'
2import { mount } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import MomentumMeter from '../../../components/MomentumMeter.vue'
5import { useGameStore } from '../../../stores/game'
6 
7describe('MomentumMeter (the run’s arc gauge, issue #62)', () => {
8 let gameStore: ReturnType<typeof useGameStore>
9 
10 beforeEach(() => {
11 setActivePinia(createPinia())
12 gameStore = useGameStore()
13 })
14 
15 it('fills one pip per point of momentum', () => {
16 gameStore.momentum = 3
17 const wrapper = mount(MomentumMeter)
18 expect(wrapper.findAll('.momentum-pip.filled')).toHaveLength(3)
19 expect(wrapper.find('[data-testid="momentum-pip-4"]').classes()).not.toContain('filled')
20 })
21 
22 it('shows the gains multiplier for the current momentum', () => {
23 gameStore.momentum = 4
24 const wrapper = mount(MomentumMeter)
25 expect(wrapper.find('[data-testid="momentum-factor"]').text()).toContain('×1.4')
26 })
27 
28 it('shows a neutral ×1.0 and an empty meter at zero momentum', () => {
29 const wrapper = mount(MomentumMeter)
30 expect(wrapper.find('[data-testid="momentum-factor"]').text()).toContain('×1.0')
31 expect(wrapper.findAll('.momentum-pip.filled')).toHaveLength(0)
32 })
33 
34 it('empties the meter when momentum drops (the shatter)', async () => {
35 gameStore.momentum = 3
36 const wrapper = mount(MomentumMeter)
37 expect(wrapper.findAll('.momentum-pip.filled')).toHaveLength(3)
38 
39 await gameStore.$patch({ momentum: 0 })
40 await wrapper.vm.$nextTick()
41 
42 expect(wrapper.find('[data-testid="momentum-meter"]').attributes('data-momentum')).toBe('0')
43 expect(wrapper.findAll('.momentum-pip.filled')).toHaveLength(0)
44 })
45 
46 // Issue #139: the shatter is animation-only and mute on CAUSE — a glancing or
47 // reduced-motion player sees the pips empty but isn't told a loss did it.
48 it('names the cause in the hint when the arc breaks, then settles back', async () => {
49 vi.useFakeTimers()
50 try {
51 gameStore.momentum = 3
52 const wrapper = mount(MomentumMeter)
53 // Before the break: the steady-state coaching hint.
54 expect(wrapper.find('p').text()).toBe('A coherent arc amplifies every gain')
55 
56 await gameStore.$patch({ momentum: 0 })
57 await wrapper.vm.$nextTick()
58 
59 // The hint now names WHY the arc broke...
60 expect(wrapper.find('p').text()).toMatch(/loss reset your momentum/i)
61 // ...and assistive tech is told too, via the existing live region.
62 expect(wrapper.find('[aria-live="polite"]').text()).toMatch(/arc broke/i)
63 
64 // Transient: after a beat it settles back to the normal zero-state hint.
65 vi.advanceTimersByTime(2600)
66 await wrapper.vm.$nextTick()
67 expect(wrapper.find('p').text()).toBe('Build on the thread to gather momentum')
68 } finally {
69 vi.useRealTimers()
70 }
71 })
72 
73 it('clears a lingering reset notice the moment momentum climbs again', async () => {
74 vi.useFakeTimers()
75 try {
76 gameStore.momentum = 2
77 const wrapper = mount(MomentumMeter)
78 
79 await gameStore.$patch({ momentum: 0 })
80 await wrapper.vm.$nextTick()
81 expect(wrapper.find('p').text()).toMatch(/loss reset your momentum/i)
82 
83 // A recovery supersedes the reset line at once — no stale 'a loss reset
84 // you' lingering while the player is already climbing back.
85 await gameStore.$patch({ momentum: 1 })
86 await wrapper.vm.$nextTick()
87 expect(wrapper.find('p').text()).toBe('A coherent arc amplifies every gain')
88 } finally {
89 vi.useRealTimers()
90 }
91 })
⋯ 1 line hidden (lines 92–92)
92})