What changed, and why

The game played in silence. Mechanically it is a form — type a message, click send, watch numbers move — yet the mechanics are dramatic: a D20 deciding fate, a timeline rewriting itself, a last-stand wager. This change gives those beats a voice: short, tactile cues (a wooden die's thunk, a wax-seal stamp as a change lands, a quill flourish, a heartbeat under the stake) that punctuate the meaningful moments and stay quiet the rest of the time.

The feature is four small layers plus the UI that mounts them. Each has one job, and they depend in one direction: the engine reads the preference, the wiring drives the engine, the page mounts the wiring. The store underneath knows nothing about sound.

LayerFileRole
Preferencestores/audio.tsmuted / volume, persisted like the dark toggle
Synthesisutils/sfx.tsevery cue, generated from oscillators + noise at play time
Enginecomposables/useSoundscape.tsone AudioContext, a volume bus, the gesture unlock
Wiringcomposables/useGameSoundscape.tswatches store transitions, fires the matching cue
UIpages/play.vue · components/FigurePicker.vuethe header mute control + the gesture-driven cues

The preference: a mute that persists

The only thing the soundscape remembers is whether it is muted and how loud. That lives in a tiny Pinia store, modeled exactly on the existing coaching store: a single localStorage key, read once on the client via hydrate(), with the absence of localStorage serving as the SSR guard.

The one judgment call is the default. With nothing saved, the game boots on at a gentle level — booting silent would undersell the feature — **except under prefers-reduced-motion, where it boots muted.** A player who has asked for less motion has signalled a low-sensory preference; starting silent (and trivially un-muted) is the conservative reading. Their first touch of the control overrides it for good.

The reduced-motion probe, the effectiveVolume getter (mute = 0), and the idempotent hydrate().

stores/audio.ts · 123 lines
stores/audio.ts123 lines · TypeScript
⋯ 58 lines hidden (lines 1–58)
1import { defineStore } from 'pinia'
2 
3/**
4 * Audio store — the soundscape's sole persisted preference (issue #92): whether
5 * cues are muted, and at what level. Deliberately game-AGNOSTIC, the same stance
6 * as the coaching store: it never imports the game store or the Web Audio engine.
7 * The engine reads it; pages/play.vue mounts the soundscape once and binds the
8 * header control to it. So a resetGame() leaves the player's sound choice untouched.
9 *
10 * Persistence mirrors the coaching store exactly — a single localStorage key, read
11 * once client-side via hydrate(), SSR-safe (storage absence is the guard).
12 */
13 
14const STORAGE_KEY = 'revisionist:audio:v1'
15 
16/** Booted-on, but gentle: the game should not startle, and is trivially muted. */
17export const DEFAULT_VOLUME = 0.7
18 
19interface PersistedAudio {
20 muted: boolean
21 volume: number
23 
24/** Clamp an untrusted level into the 0..1 the master gain expects. */
25function clampVolume(v: number): number {
26 if (!Number.isFinite(v)) return DEFAULT_VOLUME
27 return Math.max(0, Math.min(1, v))
29 
30// localStorage only exists in the browser — its absence is the SSR guard, so this
31// is inert on the server and never trips hydration. hydrate() is called from a
32// client-only onMounted; this is the belt to that suspenders.
33function readPersisted(): PersistedAudio | null {
34 if (typeof localStorage === 'undefined') return null
35 try {
36 const raw = localStorage.getItem(STORAGE_KEY)
37 if (!raw) return null
38 const parsed = JSON.parse(raw) as Partial<PersistedAudio>
39 if (typeof parsed.muted !== 'boolean' || typeof parsed.volume !== 'number') return null
40 return { muted: parsed.muted, volume: clampVolume(parsed.volume) }
41 } catch {
42 /* malformed, or storage blocked — treat as no preference */
43 }
44 return null
46 
47function writePersisted(pref: PersistedAudio): void {
48 if (typeof localStorage === 'undefined') return
49 try {
50 localStorage.setItem(STORAGE_KEY, JSON.stringify(pref))
51 } catch {
52 /* storage blocked (private mode / quota) — the choice just won't persist */
53 }
55 
56/** A low-sensory signal. With no stored preference we boot muted under it — the
57 * conservative sensory stance: silent until the player opts in, not startled into
58 * it. SSR/test-safe (stays false when matchMedia is unavailable). */
59function prefersReducedMotion(): boolean {
60 if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
61 try {
62 return window.matchMedia('(prefers-reduced-motion: reduce)').matches
63 } catch {
64 return false
65 }
67 
⋯ 14 lines hidden (lines 68–81)
68export const useAudioStore = defineStore('audio', {
69 state: () => ({
70 // Inert defaults until the first client read. muted stays false on the server
71 // so SSR and the first client paint agree; hydrate() decides the real boot
72 // state (a stored choice, or muted-under-reduced-motion).
73 muted: false,
74 volume: DEFAULT_VOLUME,
75 // false on the server and until the first client read — the same no-flash
76 // discipline as the coaching store.
77 hydrated: false
78 }),
79 
80 getters: {
81 /** The master-gain target: silent when muted, else the chosen level. */
82 effectiveVolume: (s): number => (s.muted ? 0 : s.volume)
83 },
84 
85 actions: {
86 /** Read the persisted preference once, client-side. Idempotent. With nothing
87 * on disk, default unmuted at a gentle level — but muted under reduced-motion,
88 * the conservative sensory stance. */
89 hydrate() {
90 if (this.hydrated) return
91 const persisted = readPersisted()
92 if (persisted) {
93 this.muted = persisted.muted
94 this.volume = persisted.volume
95 } else {
96 this.muted = prefersReducedMotion()
97 this.volume = DEFAULT_VOLUME
98 }
99 this.hydrated = true
100 },
101 
102 setMuted(muted: boolean) {
103 this.muted = muted
104 this.persist()
⋯ 19 lines hidden (lines 105–123)
105 },
106 
107 toggleMute() {
108 this.setMuted(!this.muted)
109 },
110 
111 /** Set the level (0..1). Touching the dial up from silence also unmutes —
112 * a non-zero volume the player can't hear would be a confusing dead control. */
113 setVolume(volume: number) {
114 this.volume = clampVolume(volume)
115 if (this.volume > 0) this.muted = false
116 this.persist()
117 },
118 
119 persist() {
120 writePersisted({ muted: this.muted, volume: this.volume })
121 }
122 }
123})

The cues: synthesized, not sampled

Every cue is a small function that builds a node graph on the audio context and schedules it. Two primitives carry almost all of them — tone (one oscillator with an attack/decay envelope and an optional pitch glide) and noise (a filtered white-noise burst). A wooden thunk is a triangle wave dropping fast in pitch plus a noise click; a wax stamp is a low press-thud, a lowpassed squish, and a tiny lift; a whoosh is band-passed noise sweeping down.

The cue set is a typed registry keyed by CueName, so the wiring can only ask for a cue that exists. The one stateful piece is the dice rattle: a looping, tremolo'd noise bed with an explicit stop(), since a one-shot can't model "rattle until the die lands."

The tone primitive; the registry opening (dispatch, the land); the signature wax-seal stamp; the rattle loop.

utils/sfx.ts · 329 lines
utils/sfx.ts329 lines · TypeScript
⋯ 65 lines hidden (lines 1–65)
1/**
2 * SFX — the soundscape's procedural "asset" layer (issue #92).
3 *
4 * Every cue is SYNTHESIZED at play time from oscillators and filtered noise — there
5 * are no sampled clips. That is a deliberate sourcing decision: generated audio is
6 * license-clean by construction (nothing third-party to license for a monetized
7 * game), adds zero bytes to the bundle, and gives Web Audio's low-latency, overlapping
8 * voices for free. The aesthetic target matches the editorial-minimal visual brand:
9 * tactile and analog (a wooden die on a desk, a quill scratch, a wax-seal stamp, paper,
10 * a soft chime), never arcade bleeps. Levels are tuned per cue so none is jarring at
11 * the gentle default master volume.
12 *
13 * Each cue is a pure scheduler: it builds a short node graph on `ctx`, routed into
14 * `out` (the engine's master gain), starting at `t0`. No global state, no imports of
15 * app stores — so the whole layer is unit-testable against a mock AudioContext.
16 */
17 
18/** A short procedural cue. `intensity` (default 1) scales the few cues that vary by
19 * magnitude (the die land, momentum pips/shatter); the rest ignore it. */
20export type Sfx = (ctx: BaseAudioContext, out: AudioNode, t0: number, intensity?: number) => void
21 
22/** The smallest audible target for an exponential ramp — Web Audio forbids ramping
23 * to a true 0, so envelopes decay to this near-silence instead. */
24const FLOOR = 0.0001
25 
26// ── Shared building blocks ──────────────────────────────────────────────────
27 
28const noiseCache = new WeakMap<BaseAudioContext, AudioBuffer>()
29 
30/** A ~1.2s mono white-noise buffer, cached per context. Generated with a fixed
31 * xorshift PRNG so the texture is deterministic (and never reaches for Math.random). */
32function noiseBuffer(ctx: BaseAudioContext): AudioBuffer {
33 const cached = noiseCache.get(ctx)
34 if (cached) return cached
35 const sr = ctx.sampleRate || 44100
36 const buf = ctx.createBuffer(1, Math.max(1, Math.floor(sr * 1.2)), sr)
37 const data = buf.getChannelData(0)
38 let s = 0x2545f491
39 for (let i = 0; i < data.length; i++) {
40 s ^= s << 13
41 s ^= s >>> 17
42 s ^= s << 5
43 data[i] = ((s >>> 0) / 0xffffffff) * 2 - 1
44 }
45 noiseCache.set(ctx, buf)
46 return buf
48 
49interface ToneOpts {
50 type?: OscillatorType
51 /** Start frequency. */
52 f0: number
53 /** Optional end frequency — a glissando from f0 over the cue's duration. */
54 f1?: number
55 dur: number
56 peak: number
57 /** Time from t0 to reach peak (the attack). */
58 attack?: number
59 /** Static detune in cents — a little for warmth, a lot for sourness. */
60 detune?: number
61 /** Delay before this voice starts, relative to t0. */
62 delay?: number
64 
65/** One oscillator voice with an attack/decay amplitude envelope and optional pitch glide. */
66function tone(ctx: BaseAudioContext, out: AudioNode, t0: number, o: ToneOpts): void {
67 const start = t0 + (o.delay ?? 0)
68 const osc = ctx.createOscillator()
69 osc.type = o.type ?? 'sine'
70 osc.frequency.setValueAtTime(o.f0, start)
71 if (o.f1 != null && o.f1 !== o.f0) {
72 osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f1), start + o.dur)
73 }
74 if (o.detune) osc.detune.setValueAtTime(o.detune, start)
75 const g = ctx.createGain()
76 const attack = o.attack ?? 0.005
77 g.gain.setValueAtTime(FLOOR, start)
78 g.gain.exponentialRampToValueAtTime(o.peak, start + attack)
79 g.gain.exponentialRampToValueAtTime(FLOOR, start + o.dur)
80 osc.connect(g)
81 g.connect(out)
82 osc.start(start)
83 osc.stop(start + o.dur + 0.05)
85 
86interface NoiseOpts {
87 type?: BiquadFilterType
88 freq: number
89 /** Optional filter-frequency sweep target — the body of a whoosh / rustle. */
90 freqTo?: number
91 q?: number
92 dur: number
93 peak: number
94 attack?: number
95 delay?: number
97 
⋯ 51 lines hidden (lines 98–148)
98/** One filtered-noise burst with an amplitude envelope — the breath behind whooshes,
99 * paper, the seal's press, the glassy shatter. */
100function noise(ctx: BaseAudioContext, out: AudioNode, t0: number, o: NoiseOpts): void {
101 const start = t0 + (o.delay ?? 0)
102 const src = ctx.createBufferSource()
103 src.buffer = noiseBuffer(ctx)
104 src.loop = true
105 const filter = ctx.createBiquadFilter()
106 filter.type = o.type ?? 'bandpass'
107 filter.frequency.setValueAtTime(o.freq, start)
108 if (o.freqTo != null && o.freqTo !== o.freq) {
109 filter.frequency.exponentialRampToValueAtTime(Math.max(1, o.freqTo), start + o.dur)
110 }
111 filter.Q.setValueAtTime(o.q ?? 1, start)
112 const g = ctx.createGain()
113 const attack = o.attack ?? 0.004
114 g.gain.setValueAtTime(FLOOR, start)
115 g.gain.exponentialRampToValueAtTime(o.peak, start + attack)
116 g.gain.exponentialRampToValueAtTime(FLOOR, start + o.dur)
117 src.connect(filter)
118 filter.connect(g)
119 g.connect(out)
120 src.start(start)
121 src.stop(start + o.dur + 0.05)
123 
124// ── The cues ────────────────────────────────────────────────────────────────
125 
126export type CueName =
127 | 'dispatch'
128 | 'diceLand'
129 | 'outcomeCritSuccess'
130 | 'outcomeSuccess'
131 | 'outcomeNeutral'
132 | 'outcomeFailure'
133 | 'outcomeCritFailure'
134 | 'craftMasterful'
135 | 'craftReckless'
136 | 'progressGain'
137 | 'progressLoss'
138 | 'momentumBuild'
139 | 'momentumShatter'
140 | 'ledgerStamp'
141 | 'chroniclePage'
142 | 'victory'
143 | 'defeat'
144 | 'stake'
145 | 'denied'
146 | 'blocked'
147 | 'uiTick'
148 
149export const SFX: Record<CueName, Sfx> = {
150 // Words whisked into the past — a soft downward-sweeping breath.
151 dispatch: (ctx, out, t0) => {
152 noise(ctx, out, t0, { type: 'bandpass', freq: 1900, freqTo: 360, q: 0.8, dur: 0.34, peak: 0.16, attack: 0.05 })
153 tone(ctx, out, t0, { type: 'sine', f0: 520, f1: 230, dur: 0.3, peak: 0.05 })
154 },
155 
156 // The wooden die meeting the desk: a pitched knock that drops fast, with a tiny
157 // transient click for the wood. intensity scales the weight (soft/strong/crit).
158 diceLand: (ctx, out, t0, intensity = 1) => {
159 const peak = 0.28 * intensity
160 tone(ctx, out, t0, { type: 'triangle', f0: 168, f1: 68, dur: 0.18, peak, attack: 0.002 })
161 noise(ctx, out, t0, { type: 'highpass', freq: 2600, q: 0.6, dur: 0.035, peak: 0.12 * intensity, attack: 0.001 })
162 },
163 
164 // The five outcome stings, keyed to the bands. Crit-success is the one earned
⋯ 56 lines hidden (lines 165–220)
165 // fanfare; neutral is barely there (a shrug should not sing).
166 outcomeCritSuccess: (ctx, out, t0) => {
167 tone(ctx, out, t0, { type: 'triangle', f0: 523, dur: 0.16, peak: 0.16, delay: 0 })
168 tone(ctx, out, t0, { type: 'triangle', f0: 659, dur: 0.16, peak: 0.16, delay: 0.09 })
169 tone(ctx, out, t0, { type: 'triangle', f0: 784, dur: 0.28, peak: 0.18, delay: 0.18 })
170 tone(ctx, out, t0, { type: 'sine', f0: 1568, dur: 0.22, peak: 0.06, delay: 0.18, attack: 0.01 })
171 },
172 outcomeSuccess: (ctx, out, t0) => {
173 tone(ctx, out, t0, { type: 'triangle', f0: 587, f1: 784, dur: 0.22, peak: 0.14 })
174 },
175 outcomeNeutral: (ctx, out, t0) => {
176 tone(ctx, out, t0, { type: 'sine', f0: 320, dur: 0.1, peak: 0.05 })
177 },
178 outcomeFailure: (ctx, out, t0) => {
179 tone(ctx, out, t0, { type: 'triangle', f0: 330, f1: 220, dur: 0.26, peak: 0.13 })
180 },
181 // A dissonant low thud — two clashing voices and a knock. The floor falling out.
182 outcomeCritFailure: (ctx, out, t0) => {
183 tone(ctx, out, t0, { type: 'sawtooth', f0: 116, dur: 0.34, peak: 0.12, detune: 0 })
184 tone(ctx, out, t0, { type: 'sawtooth', f0: 110, dur: 0.34, peak: 0.12, detune: 14 })
185 tone(ctx, out, t0, { type: 'sine', f0: 70, dur: 0.22, peak: 0.16, attack: 0.002 })
186 },
187 
188 // Grade grace notes — only the extremes speak, fired a beat after the land so they
189 // read as a flourish, not a pile-up. A bright quill flick vs. a sour scratch.
190 craftMasterful: (ctx, out, t0) => {
191 tone(ctx, out, t0, { type: 'sine', f0: 940, f1: 1680, dur: 0.14, peak: 0.07, attack: 0.008 })
192 },
193 craftReckless: (ctx, out, t0) => {
194 tone(ctx, out, t0, { type: 'square', f0: 300, f1: 250, dur: 0.12, peak: 0.05, detune: 30 })
195 },
196 
197 // The gauge moving — a clean rising tone for a gain, a darker falling one for a loss.
198 progressGain: (ctx, out, t0) => {
199 tone(ctx, out, t0, { type: 'sine', f0: 440, f1: 660, dur: 0.22, peak: 0.1, attack: 0.01 })
200 },
201 progressLoss: (ctx, out, t0) => {
202 tone(ctx, out, t0, { type: 'triangle', f0: 340, f1: 215, dur: 0.26, peak: 0.1, attack: 0.01 })
203 },
204 
205 // Momentum: an ascending pip whose pitch climbs with the arc (intensity = pip #),
206 // and a glassy break when it shatters (intensity carries a heavier full collapse).
207 momentumBuild: (ctx, out, t0, intensity = 1) => {
208 const step = Math.max(1, Math.min(4, Math.round(intensity)))
209 const f0 = 523 * Math.pow(2, (step - 1) / 6) // ~two semitones per pip
210 tone(ctx, out, t0, { type: 'sine', f0, dur: 0.14, peak: 0.1, attack: 0.006 })
211 },
212 momentumShatter: (ctx, out, t0, intensity = 1) => {
213 const peak = 0.13 * intensity
214 noise(ctx, out, t0, { type: 'bandpass', freq: 5200, freqTo: 900, q: 1.2, dur: 0.34, peak, attack: 0.002 })
215 tone(ctx, out, t0, { type: 'triangle', f0: 1320, f1: 360, dur: 0.3, peak: 0.07, detune: 22 })
216 tone(ctx, out, t0, { type: 'sine', f0: 92, dur: 0.16, peak: 0.1 * intensity, attack: 0.002 })
217 },
218 
219 // The signature cue: a change sealed into the Spine. A low press-thud, a short
220 // lowpassed "squish" of the wax, and a small click as the stamp lifts.
221 ledgerStamp: (ctx, out, t0) => {
222 tone(ctx, out, t0, { type: 'sine', f0: 104, f1: 72, dur: 0.14, peak: 0.26, attack: 0.002 })
223 noise(ctx, out, t0, { type: 'lowpass', freq: 900, q: 0.7, dur: 0.1, peak: 0.16, attack: 0.003 })
224 noise(ctx, out, t0, { type: 'highpass', freq: 3200, q: 0.6, dur: 0.03, peak: 0.06, delay: 0.02 })
225 },
226 
227 // A page turning under the cross-fade — a soft paper rustle, no pitch.
⋯ 52 lines hidden (lines 228–279)
228 chroniclePage: (ctx, out, t0) => {
229 noise(ctx, out, t0, { type: 'highpass', freq: 1800, freqTo: 4200, q: 0.5, dur: 0.26, peak: 0.07, attack: 0.06 })
230 },
231 
232 // The verdict seal strikes. A warm rising chord for victory; a slow, low descent
233 // for defeat.
234 victory: (ctx, out, t0) => {
235 tone(ctx, out, t0, { type: 'triangle', f0: 392, dur: 0.6, peak: 0.1, attack: 0.04 })
236 tone(ctx, out, t0, { type: 'triangle', f0: 494, dur: 0.58, peak: 0.1, attack: 0.06, delay: 0.06 })
237 tone(ctx, out, t0, { type: 'triangle', f0: 587, dur: 0.6, peak: 0.11, attack: 0.08, delay: 0.12 })
238 tone(ctx, out, t0, { type: 'sine', f0: 784, dur: 0.5, peak: 0.05, attack: 0.1, delay: 0.18 })
239 },
240 defeat: (ctx, out, t0) => {
241 tone(ctx, out, t0, { type: 'triangle', f0: 294, dur: 0.42, peak: 0.1, attack: 0.02 })
242 tone(ctx, out, t0, { type: 'triangle', f0: 220, dur: 0.5, peak: 0.1, attack: 0.02, delay: 0.22 })
243 tone(ctx, out, t0, { type: 'sine', f0: 147, dur: 0.7, peak: 0.11, attack: 0.04, delay: 0.4 })
244 },
245 
246 // The doubling wager — a tense heartbeat under a slowly rising tone.
247 stake: (ctx, out, t0) => {
248 tone(ctx, out, t0, { type: 'sine', f0: 60, dur: 0.18, peak: 0.2, attack: 0.004 })
249 tone(ctx, out, t0, { type: 'sine', f0: 60, dur: 0.2, peak: 0.22, attack: 0.004, delay: 0.42 })
250 tone(ctx, out, t0, { type: 'triangle', f0: 196, f1: 330, dur: 0.7, peak: 0.05, attack: 0.2 })
251 },
252 
253 // A soft, unmistakable "no" — a descending minor second. `blocked` (moderation) is
254 // a lower, two-pulse, more emphatic refusal than the everyday `denied`.
255 denied: (ctx, out, t0) => {
256 tone(ctx, out, t0, { type: 'square', f0: 220, f1: 196, dur: 0.18, peak: 0.07 })
257 },
258 blocked: (ctx, out, t0) => {
259 tone(ctx, out, t0, { type: 'square', f0: 174, dur: 0.12, peak: 0.08 })
260 tone(ctx, out, t0, { type: 'square', f0: 164, dur: 0.16, peak: 0.08, delay: 0.16 })
261 noise(ctx, out, t0, { type: 'lowpass', freq: 700, q: 0.6, dur: 0.1, peak: 0.05 })
262 },
263 
264 // A quiet papery click for small selections — sparingly, never on every keystroke.
265 uiTick: (ctx, out, t0) => {
266 noise(ctx, out, t0, { type: 'highpass', freq: 3000, q: 0.6, dur: 0.025, peak: 0.05, attack: 0.001 })
267 }
269 
270/** A live, looping handle — used only by the dice rattle, which must start when the
271 * die begins shaking and stop the instant it lands (a one-shot Sfx can't model that). */
272export interface SfxLoop {
273 /** Fade the loop out and free its nodes. Idempotent. */
274 stop: (at?: number) => void
276 
277/** The die rattling in the cup while fate is undecided: band-passed noise under a fast
278 * tremolo, kept to a quiet bed so it never competes with the land. The engine starts
279 * it on the throw and stops it on the land (and on reset, via stale guards). */
280export function createRattle(ctx: BaseAudioContext, out: AudioNode): SfxLoop {
281 const t0 = ctx.currentTime
282 const src = ctx.createBufferSource()
283 src.buffer = noiseBuffer(ctx)
284 src.loop = true
285 const bp = ctx.createBiquadFilter()
286 bp.type = 'bandpass'
287 bp.frequency.setValueAtTime(1500, t0)
288 bp.Q.setValueAtTime(1.1, t0)
289 
290 const trem = ctx.createGain()
291 trem.gain.setValueAtTime(0.055, t0)
292 // A square LFO chops the bed into a clatter rather than a steady hiss.
293 const lfo = ctx.createOscillator()
294 lfo.type = 'square'
295 lfo.frequency.setValueAtTime(17, t0)
296 const lfoDepth = ctx.createGain()
297 lfoDepth.gain.setValueAtTime(0.05, t0)
298 lfo.connect(lfoDepth)
299 lfoDepth.connect(trem.gain)
300 
301 src.connect(bp)
302 bp.connect(trem)
303 trem.connect(out)
304 src.start(t0)
305 lfo.start(t0)
⋯ 24 lines hidden (lines 306–329)
306 
307 let stopped = false
308 return {
309 stop(at?: number) {
310 if (stopped) return
311 stopped = true
312 const when = at ?? ctx.currentTime
313 const end = when + 0.08
314 try {
315 trem.gain.cancelScheduledValues(when)
316 trem.gain.setValueAtTime(0.05, when)
317 trem.gain.exponentialRampToValueAtTime(FLOOR, end)
318 } catch {
319 /* a param that won't schedule — fall through to stopping the sources */
320 }
321 try {
322 src.stop(end + 0.02)
323 lfo.stop(end + 0.02)
324 } catch {
325 /* already stopped — fine */
326 }
327 }
328 }

The engine: one context, unlocked on a gesture

The engine owns the lifecycle the cues don't: a single AudioContext, a master gain the volume rides on, and the autoplay unlock. The context is created lazily — never at mount — because browsers refuse to start audio until a real user gesture. A one-time window listener (capture phase) creates and resumes the context on the first pointer or key event, then retires.

Everything is guarded for the places audio can't run. No window, no AudioContext, or a thrown constructor all resolve to the same outcome: the engine stays inert and silent instead of throwing. play() is a no-op when muted — it doesn't even create a context — so "muted" is genuinely silent, not just zero gain.

Lazy context + master bus; the first-gesture unlock listener; play() guarding mute and missing Web Audio.

composables/useSoundscape.ts · 185 lines
composables/useSoundscape.ts185 lines · TypeScript
⋯ 32 lines hidden (lines 1–32)
1import { watch, onScopeDispose } from 'vue'
2import { useAudioStore } from '~/stores/audio'
3import { SFX, createRattle, type CueName, type SfxLoop } from '~/utils/sfx'
4 
5/**
6 * useSoundscape — the Web Audio engine (issue #92). One lazily-created AudioContext
7 * and a master gain the mute/volume preference rides on, plus the cue-dispatch and
8 * the managed dice rattle. The synthesis itself lives in `utils/sfx.ts`; this is the
9 * transport: it owns the context lifecycle, the autoplay unlock, and the volume bus.
10 *
11 * Browsers block audio until a user gesture, so the context is created and resumed on
12 * the first real interaction (a one-time window listener) — never at mount. Everything
13 * is guarded for SSR and for environments without Web Audio (tests, old browsers): in
14 * those it stays inert and silent rather than throwing.
15 *
16 * The singletons are module-level so every caller shares one context and one rattle.
17 */
18 
19let ctx: AudioContext | null = null
20let master: GainNode | null = null
21let rattle: SfxLoop | null = null
22let currentGain = 0
23let gestureBound = false
24 
25/** The constructor, or null when Web Audio is unavailable (SSR, test env, old browser). */
26function audioContextCtor(): typeof AudioContext | null {
27 if (typeof window === 'undefined') return null
28 const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }
29 return w.AudioContext ?? w.webkitAudioContext ?? null
31 
32/** Lazily build the context + master bus. Returns false when Web Audio is unavailable. */
33function ensureContext(): boolean {
34 if (ctx && master) return true
35 const Ctor = audioContextCtor()
36 if (!Ctor) return false
37 try {
38 ctx = new Ctor()
39 master = ctx.createGain()
40 master.gain.setValueAtTime(currentGain, ctx.currentTime)
41 master.connect(ctx.destination)
42 return true
43 } catch {
44 ctx = null
45 master = null
46 return false
47 }
49 
50/** Glide the master bus to a new level (a short ramp, so mute/volume changes don't click). */
51function setMasterGain(v: number): void {
52 currentGain = v
⋯ 73 lines hidden (lines 53–125)
53 if (!ctx || !master) return
54 try {
55 const now = ctx.currentTime
56 master.gain.cancelScheduledValues(now)
57 master.gain.setValueAtTime(master.gain.value, now)
58 master.gain.linearRampToValueAtTime(v, now + 0.02)
59 } catch {
60 try {
61 master.gain.value = v
62 } catch {
63 /* a param that won't take a value — nothing more we can do */
64 }
65 }
67 
68/** Resume a suspended context (the autoplay unlock, and recovery after backgrounding). */
69function resumeContext(): void {
70 if (ctx && ctx.state === 'suspended') void ctx.resume()
72 
73/** Create + resume the context. Safe to call repeatedly; cheap once running. */
74function unlock(initialGain: number): void {
75 if (!ensureContext()) return
76 setMasterGain(initialGain)
77 resumeContext()
79 
80/** Tear down the engine — stops the rattle and drops the context. For component
81 * unmount and for test isolation (each test starts from a clean singleton). */
82export function resetSoundscape(): void {
83 if (rattle) {
84 rattle.stop()
85 rattle = null
86 }
87 if (ctx) {
88 try {
89 void ctx.close()
90 } catch {
91 /* already closed */
92 }
93 }
94 ctx = null
95 master = null
96 currentGain = 0
97 gestureBound = false
99 
100export interface Soundscape {
101 /** Play a one-shot cue. `delay` offsets it from now (a grace note after a land);
102 * `intensity` scales the magnitude-aware cues. No-op when muted, on the server,
103 * or without Web Audio. */
104 play: (cue: CueName, opts?: { intensity?: number; delay?: number }) => void
105 /** Start the dice rattle (idempotent — the throw begins it). No-op when muted. */
106 startRattle: () => void
107 /** Stop the dice rattle (the land ends it). Always safe. */
108 stopRattle: () => void
109 /** Create + resume the context within a user gesture. */
110 unlock: () => void
112 
113/**
114 * Wire the engine to the live mute/volume preference and the first-gesture unlock.
115 * Call once inside a component or effect scope (it registers a watcher and a listener
116 * cleaned up on scope dispose). Returns the cue-playing handle.
117 */
118export function useSoundscape(): Soundscape {
119 const audio = useAudioStore()
120 
121 // Keep the master bus in lockstep with the preference — mute is silence, and a
122 // live volume change reaches a sustained cue (the rattle) immediately.
123 watch(() => audio.effectiveVolume, (v) => setMasterGain(v), { immediate: true })
124 
125 const engineUnlock = () => unlock(audio.effectiveVolume)
126 
127 // The autoplay gate: the very first real interaction creates and resumes the
128 // context, then the listener retires. Capture-phase + once so it can't be starved.
129 if (typeof window !== 'undefined' && !gestureBound) {
130 gestureBound = true
131 const onGesture = () => {
132 engineUnlock()
133 window.removeEventListener('pointerdown', onGesture, true)
134 window.removeEventListener('keydown', onGesture, true)
135 window.removeEventListener('touchstart', onGesture, true)
136 }
137 window.addEventListener('pointerdown', onGesture, true)
138 window.addEventListener('keydown', onGesture, true)
139 window.addEventListener('touchstart', onGesture, true)
140 onScopeDispose(() => {
141 window.removeEventListener('pointerdown', onGesture, true)
142 window.removeEventListener('keydown', onGesture, true)
143 window.removeEventListener('touchstart', onGesture, true)
144 })
145 }
146 
147 const play = (cue: CueName, opts?: { intensity?: number; delay?: number }) => {
148 if (audio.muted) return
149 if (!ensureContext() || !ctx || !master) return
150 setMasterGain(audio.effectiveVolume)
151 resumeContext()
152 const fn = SFX[cue]
153 if (!fn) return
154 try {
155 fn(ctx, master, ctx.currentTime + (opts?.delay ?? 0), opts?.intensity)
156 } catch {
157 /* a malformed graph must never break the turn it accompanies */
158 }
159 }
160 
⋯ 25 lines hidden (lines 161–185)
161 const startRattle = () => {
162 if (audio.muted) return
163 if (!ensureContext() || !ctx || !master) return
164 setMasterGain(audio.effectiveVolume)
165 resumeContext()
166 if (rattle) return
167 try {
168 rattle = createRattle(ctx, master)
169 } catch {
170 rattle = null
171 }
172 }
173 
174 const stopRattle = () => {
175 if (!rattle) return
176 try {
177 rattle.stop()
178 } catch {
179 /* fall through — drop the handle regardless */
180 }
181 rattle = null
182 }
183 
184 return { play, startRattle, stopRattle, unlock: engineUnlock }

The wiring: cues from state, never scattered play()

This is the bridge, and the part a reviewer should scrutinize. It watches the store's transitions and fires the matching cue. Because the store sequences its writes through the staged turn reveal, watching those writes gives the beats for free, already paced — the die lands, then the gauge swings, then the change seals onto the Spine.

The throw-to-land watch mirrors the dice component exactly: the rising loading edge is the dispatch and the rattle; the falling edge lands the die, but only for a genuinely new roll. A refunded send also flips loading off, and the index dedupe is what stops it from replaying the previous land.

The latest-roll read + the throw/land watch with its dedupe; the in-run guard on progress and momentum.

composables/useGameSoundscape.ts · 144 lines
composables/useGameSoundscape.ts144 lines · TypeScript
⋯ 60 lines hidden (lines 1–60)
1import { computed, watch, onScopeDispose } from 'vue'
2import { useGameStore } from '~/stores/game'
3import { useSoundscape, type Soundscape } from '~/composables/useSoundscape'
4import { DiceOutcome } from '~/utils/dice'
5import type { CueName } from '~/utils/sfx'
6 
7/**
8 * useGameSoundscape — the bridge from game state to cues (issue #92). It watches the
9 * store's transitions and fires the matching cue, so audio stays fully decoupled: the
10 * store knows nothing about sound, and the cues ride the same staged reveal the
11 * animations do (the store sequences the WRITES; watching those writes gives the beats
12 * for free, already paced by REVEAL).
13 *
14 * Stale-safe by construction. Every cue-bearing store write past the fetch is epoch-
15 * guarded, so a reset mid-flight produces no transition for a watcher to fire on — a
16 * stale roll's sound never plays. The one thing a reset DOES change is the zeroing of
17 * progress/momentum and the clearing of the ledger; the in-run guards below
18 * (`currentObjective`, growth-only) keep that teardown from sounding like a real loss.
19 *
20 * Call once, inside a component or effect scope. The engine + store are injectable so
21 * the wiring can be unit-tested against a spy engine and a real Pinia store, no
22 * AudioContext required.
23 */
24 
25type GameStore = ReturnType<typeof useGameStore>
26 
27/** Outcome tier → its sting. */
28const OUTCOME_CUE: Record<string, CueName> = {
29 [DiceOutcome.CRITICAL_SUCCESS]: 'outcomeCritSuccess',
30 [DiceOutcome.SUCCESS]: 'outcomeSuccess',
31 [DiceOutcome.NEUTRAL]: 'outcomeNeutral',
32 [DiceOutcome.FAILURE]: 'outcomeFailure',
33 [DiceOutcome.CRITICAL_FAILURE]: 'outcomeCritFailure'
35 
36/** Only the extreme grades get a flourish — the middle three would just be chatter. */
37const CRAFT_CUE: Record<string, CueName> = {
38 masterful: 'craftMasterful',
39 reckless: 'craftReckless'
41 
42/** How heavy the wooden land reads — a crit lands harder than a shrug. */
43function landIntensity(outcome: string): number {
44 if (outcome === DiceOutcome.CRITICAL_SUCCESS || outcome === DiceOutcome.CRITICAL_FAILURE) return 1.3
45 if (outcome === DiceOutcome.SUCCESS || outcome === DiceOutcome.FAILURE) return 1.0
46 return 0.7
48 
49export interface GameSoundscapeOptions {
50 gameStore?: GameStore
51 engine?: Soundscape
53 
54export function useGameSoundscape(options: GameSoundscapeOptions = {}): Soundscape {
55 const game = options.gameStore ?? useGameStore()
56 const engine = options.engine ?? useSoundscape()
57 
58 // The latest resolved roll (mirrors DiceRoller's read): the last AI message that
59 // carries one. `index` is the dedupe key — a refunded send flips loading off without
60 // a new roll, and must not replay the previous land.
61 const latestRoll = computed(() => {
62 const history = game.messageHistory
63 for (let i = history.length - 1; i >= 0; i--) {
64 const m = history[i]
65 if (m.sender === 'ai' && typeof m.diceRoll === 'number') {
66 return { outcome: m.diceOutcome ?? '', craft: m.craft ?? '', index: i }
67 }
68 }
69 return null
70 })
71 let lastLandedIndex = latestRoll.value?.index ?? -1
72 
73 // Throw → land. The rising loading edge is the dispatch (whoosh + the rattle starts);
74 // the falling edge lands the die — the wooden thunk, the tiered outcome sting, and a
75 // grade grace note a beat later for the extremes. The new-roll guard means a refunded
76 // send only stops the rattle, never replays a land.
77 watch(() => game.isLoading, (loading, was) => {
78 if (loading && !was) {
79 engine.play('dispatch')
80 engine.startRattle()
81 return
82 }
83 if (!loading && was) {
84 engine.stopRattle()
85 const latest = latestRoll.value
86 if (!latest || latest.index === lastLandedIndex) return
87 lastLandedIndex = latest.index
88 engine.play('diceLand', { intensity: landIntensity(latest.outcome) })
89 const sting = OUTCOME_CUE[latest.outcome]
90 if (sting) engine.play(sting)
91 const craft = CRAFT_CUE[latest.craft]
92 if (craft) engine.play(craft, { delay: 0.12 })
93 }
94 })
95 
96 // Re-arm the land dedupe when a reset wipes history, so a new run's first roll lands.
97 watch(latestRoll, (r) => { if (!r) lastLandedIndex = -1 })
98 
⋯ 2 lines hidden (lines 99–100)
99 // The gauge swing — rising tone on a gain, darker one on a loss. Gated on an active
100 // run so resetGame()'s zeroing (currentObjective already null at flush) stays silent.
101 watch(() => game.objectiveProgress, (now, was) => {
102 if (!game.currentObjective) return
103 const change = now - was
104 if (change > 0) engine.play('progressGain')
105 else if (change < 0) engine.play('progressLoss')
106 })
107 
108 // Momentum climb vs. shatter (same run guard). The pip pitch climbs with the arc;
109 // a full collapse to empty breaks heavier than a partial fall.
110 watch(() => game.momentum, (now, was) => {
111 if (!game.currentObjective) return
112 if (now > was) engine.play('momentumBuild', { intensity: now })
113 else if (now < was) engine.play('momentumShatter', { intensity: now === 0 ? 1.3 : 1 })
⋯ 31 lines hidden (lines 114–144)
114 })
115 
116 // The signature cue: a change sealed onto the Spine. Growth only — a reset clears the
117 // ledger (a shrink), which never stamps.
118 watch(() => game.timelineEvents.length, (now, was) => {
119 if (now > was) engine.play('ledgerStamp')
120 })
121 
122 // The Chronicle rewriting itself — a soft page-turn under the cross-fade, on each new
123 // telling. Never on the reset-to-null (guarded by the truthy check).
124 watch(() => game.chronicle, (now, was) => {
125 if (now && now !== was) engine.play('chroniclePage')
126 })
127 
128 // The verdict seal strikes.
129 watch(() => game.gameStatus, (status) => {
130 if (status === 'victory') engine.play('victory')
131 else if (status === 'defeat') engine.play('defeat')
132 })
133 
134 // Denials — a soft, unmistakable "no". Moderation is the harder, distinct block; the
135 // everyday rejection (no figure, rate limit, infra hiccup) gets the lighter one.
136 watch(() => game.moderationNotice, (now, was) => { if (now && !was) engine.play('blocked') })
137 watch(() => game.error, (now, was) => { if (now && !was) engine.play('denied') })
138 watch(() => game.isRateLimited, (now, was) => { if (now && !was) engine.play('denied') })
139 
140 // This wiring owns the rattle's lifetime — stop it if the host scope tears down.
141 onScopeDispose(() => engine.stopRattle())
142 
143 return engine

The UI: a header control and one mount

The page wires it together in one place. useGameSoundscape() is called once in setup — that single call registers every watcher and returns the engine handle for the few cues a UI action drives directly (the stake swell, the figure-pick tick). The preference is hydrated in the existing client onMounted, beside coaching's.

The control is a mute toggle in the header, next to the dark-mode button and built the same way. The click is itself a gesture, so un-muting unlocks the context right there and chirps a tick to confirm sound is live. The accessible name reflects state, aria-pressed tracks it, and the icon is aria-hidden.

The header mute button (decorative svg, named button); the one mount; toggleMute unlocking on its own gesture.

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

The figure-pick tick is the only other component touch: a single quiet cue at the chokepoint every deliberate selection already passes through.

One papery tick in choose() — the one place every figure selection lands.

components/FigurePicker.vue · 541 lines
components/FigurePicker.vue541 lines · Vue
⋯ 371 lines hidden (lines 1–371)
1<template>
2 <div data-testid="figure-picker" class="space-y-3">
3 <div ref="searchBoxRef" class="relative" @focusout="onSearchFocusOut">
4 <label for="figure-input" class="rv-label block mb-1">Who in history will you reach?</label>
5 <input id="figure-input" ref="searchInputRef" v-model="figure" data-testid="figure-input" type="text"
6 class="rv-field rv-mono text-sm" placeholder="Anyone, any era — Cleopatra, Tesla, Genghis Khan…"
7 :disabled="disabled"
8 role="combobox" aria-autocomplete="list" :aria-expanded="listboxOpen ? 'true' : 'false'"
9 :aria-controls="listboxOpen ? 'figure-search-listbox' : undefined"
10 :aria-activedescendant="activeIndex >= 0 ? `figure-search-option-${activeIndex}` : undefined"
11 autocomplete="off" @keydown="onSearchKeydown" />
12 <span class="sr-only" role="status">{{ searchStatus }}</span>
13 <!-- Name autocomplete: Wikipedia title search, filtered to people (#74) so the
14 picks are all real, contactable figures (the description line still tells
15 one Cleopatra from another). Picking one fills the input; grounding fires
16 exactly as if it had been typed. -->
17 <ul v-if="listboxOpen" id="figure-search-listbox" data-testid="figure-search-listbox" role="listbox"
18 aria-label="Matching names from the record"
19 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border rv-line rounded-sm rv-bg max-h-64 overflow-y-auto">
20 <li v-for="(r, i) in searchResults" :id="`figure-search-option-${i}`" :key="r.name" role="option"
21 :aria-selected="i === activeIndex ? 'true' : 'false'" data-testid="figure-search-option"
22 class="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm"
23 :class="i === activeIndex ? 'rv-tint' : 'rv-hover'"
24 @mousedown.prevent @click="choose(r.name)" @mousemove="activeIndex = i">
25 <img v-if="r.thumbnail" :src="r.thumbnail" alt="" class="h-7 w-7 shrink-0 rounded object-cover" aria-hidden="true" />
26 <span v-else class="h-7 w-7 shrink-0 rounded rv-tint" aria-hidden="true" />
27 <span class="min-w-0">
28 <span class="rv-fg font-medium block truncate">{{ r.name }}</span>
29 <span v-if="r.description" class="rv-faint text-[11px] block truncate">{{ r.description }}</span>
30 </span>
31 </li>
32 </ul>
33 <!-- The search couldn't reach Wikipedia after its retries: a visible, retryable
34 hint so a rate-limit blip degrades honestly instead of an empty box that
35 reads as "no matches" (issue #58). The sr-only role=status span above is the
36 sole live region — this surface is NOT one, to avoid a double announcement. -->
37 <div v-else-if="searchOpen && searchUnavailable" data-testid="figure-search-unavailable"
38 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border rv-line rounded-sm rv-bg px-2.5 py-2 text-sm rv-muted flex items-center justify-between gap-2">
39 <span>Couldn't reach the record — a brief network hiccup.</span>
40 <button type="button" data-testid="figure-search-retry"
41 class="rv-accent text-[11px] font-medium hover:underline shrink-0"
42 @mousedown.prevent @click="retrySearch">Retry</button>
43 </div>
44 </div>
45 
46 <!-- Grounding: who they were, and when you reach them -->
47 <div v-if="groundingLoading" data-testid="grounding-loading" class="flex items-center gap-2 text-xs rv-faint">
48 <span class="rv-spinner" aria-hidden="true" />
49 Consulting the records…
50 </div>
51 
52 <div v-else-if="grounding?.resolved" data-testid="figure-dossier" class="rv-card p-3">
53 <div class="flex gap-3">
54 <img v-if="grounding.thumbnail" :src="grounding.thumbnail" :alt="grounding.name"
55 class="h-12 w-12 shrink-0 rounded object-cover" />
56 <div class="min-w-0 flex-1">
57 <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
58 <span data-testid="dossier-name" class="rv-fg font-semibold text-sm">{{ grounding.name }}</span>
59 <span v-if="lifespan" data-testid="dossier-lifespan" class="rv-mono text-[11px] rv-faint">{{ lifespan }}</span>
60 <a v-if="grounding.wikiUrl" :href="grounding.wikiUrl" target="_blank" rel="noopener noreferrer"
61 class="rv-accent text-[11px]">Wikipedia ↗</a>
62 </div>
63 <p v-if="grounding.description" class="rv-muted text-xs mt-0.5">{{ grounding.description }}</p>
64 
65 <!-- Deceased-only floor (#72): a living (or undatable) figure can't be
66 reached. Show an honest block in place of the when-control; the send is
67 gated by canContact, and the server re-checks authoritatively. -->
68 <p v-if="contactBlocked" data-testid="contact-blocked" role="alert"
69 class="mt-2.5 border-t rv-line pt-2.5 text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
70 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ contactBlockReason }}
71 </p>
72 
73 <!-- When do you reach them? A promoted, lifetime-bounded playhead: the
74 chosen year + live age lead, big and in the accent, so the moment you're
75 writing into is the loudest thing in the dossier (not a buried micro-row). -->
76 <div v-else-if="hasLifespan" data-testid="when-control" class="mt-2.5 border-t rv-line pt-2.5">
77 <div class="flex items-baseline justify-between gap-2">
78 <span id="when-label" class="rv-label">Reach them in</span>
79 <span data-testid="when-display" class="rv-mono rv-accent text-lg font-bold leading-none">
80 {{ whenDisplay }}<span v-if="contactAge != null" data-testid="when-age" class="rv-faint text-xs font-normal"> · age {{ contactAge }}</span>
81 </span>
82 </div>
83 <input type="range" data-testid="when-slider" class="w-full mt-2" :style="{ accentColor: 'var(--rv-accent)' }"
84 :min="range.min" :max="range.max" :value="contactWhen ?? range.min" :disabled="disabled"
85 aria-labelledby="when-label" :aria-valuetext="whenValueText" @input="onWhenInput" />
86 <div class="flex items-center justify-between text-[10px] rv-faint rv-mono">
87 <span>{{ grounding.born?.display }} · born</span>
88 <span>{{ grounding.died?.display ? grounding.died.display + ' · died' : 'present' }}</span>
89 </div>
90 
91 <!-- Pin the moment (issue #32): an optional month/day refinement of the
92 chosen year. Display + prompt flavor only — the year stays the value
93 every mechanic computes with, so the wager, world-brief, and liveness
94 gates are untouched by whatever is pinned here. -->
95 <div class="mt-1.5">
96 <button v-if="!momentOpen" type="button" data-testid="pin-moment"
97 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
98 :disabled="disabled" @click="momentOpen = true">
99 <span aria-hidden="true">📍</span> {{ contactMoment ? 'adjust the moment' : 'pin the moment' }}
100 </button>
101 <div v-else data-testid="moment-picker">
102 <div class="flex flex-wrap gap-1" role="group" aria-label="Month of the contact">
103 <button v-for="(m, i) in MONTH_NAMES" :key="m" type="button" data-testid="month-chip"
104 class="rv-press border rv-line rounded-sm px-1.5 py-0.5 text-[10px]"
105 :class="contactMoment?.month === i + 1 ? 'rv-tint rv-fg' : 'rv-muted rv-hover'"
106 :aria-pressed="contactMoment?.month === i + 1 ? 'true' : 'false'"
107 :disabled="disabled" @click="pickMonth(i + 1)">{{ m.slice(0, 3) }}</button>
108 </div>
109 <div class="flex items-center gap-1.5 mt-1">
110 <template v-if="contactMoment">
111 <label for="moment-day" class="rv-label">day</label>
112 <input id="moment-day" data-testid="moment-day" type="number" inputmode="numeric"
113 class="rv-field rv-mono text-xs w-16 px-1.5 py-0.5" min="1" :max="maxDay"
114 :value="contactMoment.day ?? ''" :disabled="disabled" placeholder="—"
115 @input="onDayInput" />
116 </template>
117 <button type="button" data-testid="unpin-moment"
118 class="rv-faint text-[11px] hover:underline ml-auto" :disabled="disabled"
119 @click="unpinMoment">{{ contactMoment ? 'unpin' : 'close' }}</button>
120 </div>
121 </div>
122 </div>
123 </div>
124 
125 <!-- Even the AI bridge couldn't date them: say so honestly instead of a
126 mysteriously absent control. The copy claims only the lookup outcome
127 (it may be a transient outage, not a dateless record). No manual year
128 here — the contact year prices the anachronism wager, so it stays
129 grounded or unset. -->
130 <p v-else data-testid="when-unknown" class="mt-2.5 border-t rv-line pt-2.5 text-[11px] italic rv-faint">
131 No birth year could be found for them — your message will find them in their own time.
132 </p>
133 
134 <!-- The Archive: study who you're reaching, at the chosen moment -->
135 <div v-if="!contactBlocked" data-testid="archive" class="mt-2 border-t rv-line pt-2">
136 <button v-if="!studyShown" type="button" data-testid="study-button"
137 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
138 :disabled="studyLoading || disabled" @click="studyThem">
139 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'The Archivist consults the record…' : studyLabel }}
140 </button>
141 
142 <div v-else data-testid="figure-study" class="space-y-1 text-[11px] leading-snug rv-muted">
143 <p class="italic rv-fg">{{ figureStudy?.atThisMoment }}</p>
144 <p v-if="figureStudy?.grasp.length"><span class="rv-faint">grasps</span> {{ figureStudy?.grasp.join(' · ') }}</p>
145 <p v-if="figureStudy?.reaching.length"><span class="rv-faint">reaching</span> {{ figureStudy?.reaching.join(' · ') }}</p>
146 <p v-if="figureStudy?.cannotYetKnow" class="rv-warn"><span class="rv-faint">beyond them</span> {{ figureStudy?.cannotYetKnow }}</p>
147 <button v-if="yearMoved" type="button" data-testid="restudy-button"
148 class="rv-accent font-medium hover:underline disabled:opacity-50" :disabled="studyLoading || disabled" @click="studyThem">
149 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'Consulting…' : restudyLabel }}
150 </button>
151 </div>
152 
153 <p v-if="studyNotice" data-testid="study-blocked" role="alert" aria-live="assertive"
154 class="mt-1 text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
155 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ studyNotice }}
156 </p>
157 </div>
158 </div>
159 </div>
160 </div>
161 
162 <div v-else-if="grounding && !grounding.resolved" data-testid="figure-unresolved" class="text-xs italic rv-faint">
163 <template v-if="grounding.transient">Couldn't reach the record for "{{ activeName }}" — try again in a moment.</template>
164 <template v-else>No record found for "{{ activeName }}" — reach for a real historical figure (pick a match as you type, or refine the name).</template>
165 </div>
166 
167 <!-- Figures already contacted this run -->
168 <div v-if="contacted.length" class="flex flex-wrap items-center gap-1.5">
169 <span class="rv-label">contacts</span>
170 <button v-for="f in contacted" :key="f.name" type="button" data-testid="contact-chip"
171 class="rv-press text-xs px-2 py-0.5 border rv-line rounded-sm inline-flex items-center gap-1.5" :class="chipClass(f.name)" @click="select(f.name)">
172 <span class="rv-dot" :class="f.name === figure ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />{{ f.name }}
173 </button>
174 </div>
175 
176 <!-- Era-relevant figures for this objective (the on-ramp); you can still type anyone. -->
177 <div class="space-y-1.5">
178 <span class="rv-label block" aria-live="polite">{{ suggestionsLabel }}</span>
179 <p v-if="suggestionsNotice" data-testid="suggestions-blocked" role="alert" aria-live="assertive"
180 class="text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
181 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ suggestionsNotice }}
182 </p>
183 <!-- While the era-aware agent works: quiet skeleton chips — never clickable
184 generic defaults masquerading as objective-relevant picks. The famous-
185 names fallback appears only AFTER the agent has genuinely come up empty,
186 labeled honestly as what it is. -->
187 <div v-if="suggestionsPending" data-testid="suggestion-skeletons" class="flex flex-col gap-1" aria-hidden="true">
188 <div v-for="i in 3" :key="i" class="border rv-line rounded-sm px-2.5 py-2">
189 <span class="skeleton-line w-32" />
190 <span class="skeleton-line w-48 mt-1.5" />
191 </div>
192 </div>
193 <div v-else class="flex flex-col gap-1">
194 <button v-for="s in displaySuggestions" :key="s.name" type="button" data-testid="suggestion-chip"
195 class="rv-press border rv-line rounded-sm px-2.5 py-1.5 text-left" :class="suggestionClass(s.name)" @click="select(s.name)">
196 <span class="text-sm rv-fg font-medium">{{ s.name }}</span>
197 <span v-if="s.lifespan" class="ml-1.5 rv-mono text-[10px] rv-faint">{{ s.lifespan }}</span>
198 <span v-if="s.reason" data-testid="suggestion-reason" class="block text-[11px] leading-snug rv-faint">{{ s.reason }}</span>
199 </button>
200 </div>
201 </div>
202 </div>
203</template>
204 
205<script setup lang="ts">
206/**
207 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
208 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
209 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
210 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
211 */
212import { computed, watch, onMounted, onUnmounted } from 'vue'
213import { useGameStore } from '~/stores/game'
214import { useSoundscape } from '~/composables/useSoundscape'
215import { formatContactMoment, MONTH_NAMES, MONTH_MAX_DAY } from '~/utils/contact-moment'
216import type { FigureSuggestion } from '~/server/utils/figure-suggester'
217 
218const figure = defineModel<string>({ default: '' })
219defineProps<{ disabled?: boolean }>()
220 
221const gameStore = useGameStore()
222// A quiet papery tick when a figure is deliberately picked (issue #92, UI micro).
223const soundscape = useSoundscape()
224const contacted = computed(() => gameStore.figures)
225const figureSuggestions = computed(() => gameStore.figureSuggestions)
226const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
227const suggestionsNotice = computed(() => gameStore.suggestionsNotice)
228const grounding = computed(() => gameStore.figureGrounding)
229const groundingLoading = computed(() => gameStore.groundingLoading)
230const contactWhen = computed(() => gameStore.contactWhen)
231const contactAge = computed(() => gameStore.contactAge)
232const activeName = computed(() => figure.value.trim())
233 
234const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
235// Deceased-only floor (#72): a resolved figure with no confirmed death is living
236// (or undatable) and can't be reached — show a block instead of the when-control.
237const contactBlocked = computed(() => !!(grounding.value?.resolved && !grounding.value.died))
238const contactBlockReason = computed(() => {
239 const g = grounding.value
240 if (!contactBlocked.value || !g) return ''
241 return g.born
242 ? `${g.name} is still living — Revisionist only reaches figures from history.`
243 : `${g.name} couldn't be dated — Revisionist can only reach figures it can place in history.`
244})
245const lifespan = computed(() => {
246 const g = grounding.value
247 if (!g?.born) return ''
248 if (g.died) return `${g.born.display}${g.died.display}`
249 return g.living ? `${g.born.display} – present` : g.born.display
250})
251const range = computed(() => ({
252 min: grounding.value?.born?.signed ?? 0,
253 max: grounding.value?.died?.signed ?? new Date().getFullYear()
254}))
255const contactMoment = computed(() => gameStore.contactMoment)
256const whenDisplay = computed(() => (contactWhen.value != null ? formatContactMoment(contactWhen.value, contactMoment.value) : ''))
257// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
258// not the raw signed integer the value attribute carries.
259const whenValueText = computed(() =>
260 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
262 
263function onWhenInput(e: Event) {
264 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
266 
267// --- Pin the moment (issue #32): month chips + optional day, year-canonical ---
268const momentOpen = ref(false)
269const maxDay = computed(() => (contactMoment.value ? MONTH_MAX_DAY[contactMoment.value.month - 1] : 31))
270function pickMonth(month: number) {
271 const current = gameStore.contactMoment
272 if (current?.month === month) return
273 // Switching months keeps the day where it stays valid, clamps where it doesn't.
274 const day = current?.day ? Math.min(current.day, MONTH_MAX_DAY[month - 1]) : undefined
275 gameStore.setContactMoment(day ? { month, day } : { month })
277function onDayInput(e: Event) {
278 const current = gameStore.contactMoment
279 if (!current) return
280 const raw = Number((e.target as HTMLInputElement).value)
281 if (!Number.isInteger(raw) || raw < 1) {
282 gameStore.setContactMoment({ month: current.month })
283 return
284 }
285 gameStore.setContactMoment({ month: current.month, day: Math.min(raw, MONTH_MAX_DAY[current.month - 1]) })
287function unpinMoment() {
288 gameStore.setContactMoment(null)
289 momentOpen.value = false
291 
292// The Archive: study the grounded figure in-game, at the chosen moment.
293const figureStudy = computed(() => gameStore.figureStudy)
294const studyLoading = computed(() => gameStore.studyLoading)
295const studyNotice = computed(() => gameStore.studyNotice)
296const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
297const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
298const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
299const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
300function studyThem() { gameStore.studyActiveFigure() }
301 
302const LOCAL_DEFAULT: FigureSuggestion[] = [
303 { name: 'Cleopatra', reason: '' },
304 { name: 'Nikola Tesla', reason: '' },
305 { name: 'Genghis Khan', reason: '' },
306 { name: 'Marie Curie', reason: '' },
307 { name: 'Leonardo da Vinci', reason: '' }
309const displaySuggestions = computed(() => (figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT))
310// Pending = in flight, OR simply not yet asked for this objective — the latter
311// covers the first painted frame (loadSuggestions fires onMounted, after render),
312// which would otherwise flash the clickable famous-names fallback for one frame.
313// The store records suggestionsFor even on failure, so a genuine miss still
314// settles into the honest fallback rather than skeletons forever.
315const suggestionsPending = computed(() =>
316 !figureSuggestions.value.length && (
317 suggestionsLoading.value ||
318 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
319 )
321const suggestionsLabel = computed(() =>
322 suggestionsPending.value
323 ? 'finding figures who matter here…'
324 : figureSuggestions.value.length
325 ? 'figures who might matter here'
326 : 'some famous names to start with'
328 
329function select(name: string) {
330 choose(name)
332 
333// --- Name autocomplete (a combobox over Wikipedia title search) ---
334interface SearchResult { name: string; description?: string; thumbnail?: string }
335const searchBoxRef = ref<HTMLElement | null>(null)
336const searchInputRef = ref<HTMLInputElement | null>(null)
337const searchResults = ref<SearchResult[]>([])
338const searchOpen = ref(false)
339// Sustained weather (rate-limit blips) finally gave up with nothing to show: a
340// visible, retryable hint takes the popup so an outage degrades honestly instead
341// of an empty box that reads as "no matches" (issue #58).
342const searchUnavailable = ref(false)
343const activeIndex = ref(-1)
344// The listbox is the result popup; the unavailable hint is a separate, non-listbox
345// surface. aria-expanded / aria-controls track only this.
346const listboxOpen = computed(() => searchOpen.value && searchResults.value.length > 0)
347let searchDebounce: ReturnType<typeof setTimeout> | null = null
348let searchSeq = 0
349// Backoff for transient (rate-limit) misses: spaced retries before giving up, so a
350// throttled prefix like "Leonardo" gets time to clear rather than vanishing.
351const SEARCH_RETRY_DELAYS_MS = [800, 1600]
352// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
353// the model change it causes must not reopen the dropdown over the fresh dossier.
354let lastChosen = ''
355 
356/** Closing also INVALIDATES any response still in flight: bumping the seq is what
357 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
358 * dossier, an emptied input, or a disabled one mid-send. It also cancels a pending
359 * backoff retry — otherwise a timer armed before the dismissal would later fire and
360 * pop the dropdown (or the unavailable hint) back up unprompted. */
361function closeSearch() {
362 if (searchDebounce) clearTimeout(searchDebounce)
363 searchDebounce = null
364 searchSeq++
365 searchResults.value = []
366 searchOpen.value = false
367 searchUnavailable.value = false
368 activeIndex.value = -1
370 
371/** Deliberate selection from any surface: fill the input and stand down. */
372function choose(name: string) {
373 lastChosen = name
374 figure.value = name
375 closeSearch()
376 soundscape.play('uiTick')
⋯ 164 lines hidden (lines 378–541)
378 
379/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
380async function runSearch(q: string, seq: number, attempt = 0) {
381 try {
382 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[]; transient?: boolean }
383 if (seq !== searchSeq) return // closed, or a newer keystroke took over
384 const results = res?.results ?? []
385 // Weather, not absence: an empty answer the server marks transient must not
386 // read as "no matches" — keep whatever is showing and retry with backoff.
387 // (A new keystroke clears the timer; the seq guard drops a stale landing.)
388 if (!results.length && res?.transient) {
389 const delay = SEARCH_RETRY_DELAYS_MS[attempt]
390 if (delay != null) {
391 searchDebounce = setTimeout(() => { void runSearch(q, seq, attempt + 1) }, delay)
392 } else if (!searchResults.value.length) {
393 // Out of retries with nothing to fall back on: say so plainly and
394 // offer a retry, rather than an empty box that looks like "no matches".
395 searchUnavailable.value = true
396 searchOpen.value = true
397 activeIndex.value = -1
398 }
399 return
400 }
401 searchUnavailable.value = false
402 searchResults.value = results
403 searchOpen.value = results.length > 0
404 activeIndex.value = -1
405 } catch {
406 if (seq === searchSeq) closeSearch()
407 }
409 
410/** Manual retry from the "unavailable" hint — refire the search for the current text.
411 * Returns focus to the input first: clearing the hint unmounts the Retry button, and
412 * a keyboard user activating it would otherwise have focus fall to <body>. */
413function retrySearch() {
414 const q = (figure.value || '').trim()
415 if (q.length < 2) return
416 searchInputRef.value?.focus()
417 searchUnavailable.value = false
418 void runSearch(q, ++searchSeq)
420 
421watch(figure, (name) => {
422 if (searchDebounce) clearTimeout(searchDebounce)
423 const q = (name || '').trim()
424 if (q.length < 2 || q === lastChosen) {
425 closeSearch()
426 // One-shot suppression: it exists to swallow the single model echo a
427 // choice causes. A later deliberate retype of the same name searches again.
428 if (q === lastChosen) lastChosen = ''
429 return
430 }
431 lastChosen = ''
432 searchUnavailable.value = false // a fresh keystroke retires any prior outage hint
433 const seq = ++searchSeq
434 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
435})
436 
437function onSearchKeydown(e: KeyboardEvent) {
438 if (e.isComposing) return // IME candidate navigation owns these keys
439 // Escape always dismisses: it must clear a populated listbox OR the unavailable
440 // hint, and — even when nothing is on screen yet — cancel a pending backoff retry
441 // and invalidate any in-flight fetch (closeSearch clears the timer and bumps the
442 // seq). Handled before the guard below, which only covers a populated listbox.
443 if (e.key === 'Escape') {
444 closeSearch()
445 return
446 }
447 if (!searchOpen.value || !searchResults.value.length) {
448 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
449 // (the only recovery after Escape that doesn't require editing the text).
450 if (e.key === 'ArrowDown') {
451 const q = (figure.value || '').trim()
452 if (q.length >= 2) {
453 e.preventDefault()
454 void runSearch(q, ++searchSeq)
455 }
456 }
457 return
458 }
459 if (e.key === 'ArrowDown') {
460 e.preventDefault()
461 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
462 } else if (e.key === 'ArrowUp') {
463 e.preventDefault()
464 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
465 } else if (e.key === 'Enter') {
466 if (activeIndex.value >= 0) {
467 e.preventDefault()
468 choose(searchResults.value[activeIndex.value].name)
469 }
470 }
471 // Escape is handled up top (it must also dismiss the unavailable hint, where
472 // searchResults is empty and this block is skipped).
474 
475/** Close when focus genuinely leaves the combobox (not when it moves within it). */
476function onSearchFocusOut(e: FocusEvent) {
477 const next = e.relatedTarget as Node | null
478 if (next && searchBoxRef.value?.contains(next)) return
479 closeSearch()
481 
482/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
483const searchStatus = computed(() =>
484 searchUnavailable.value
485 ? 'The record is unreachable right now — press the down arrow or Retry to search again.'
486 : listboxOpen.value
487 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
488 : ''
490 
491function chipClass(name: string) {
492 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
494function suggestionClass(name: string) {
495 return name === figure.value ? 'rv-tint' : 'rv-hover'
497 
498// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
499// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
500// fired during the debounce/fetch window must go out clean (free-form), never
501// wearing the old figure's facts, year, or liveness gate.
502let debounce: ReturnType<typeof setTimeout> | null = null
503watch(figure, (name) => {
504 if (debounce) clearTimeout(debounce)
505 gameStore.clearGrounding()
506 const trimmed = (name || '').trim()
507 if (!trimmed) return
508 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
509})
510onUnmounted(() => {
511 if (debounce) clearTimeout(debounce)
512 if (searchDebounce) clearTimeout(searchDebounce)
513})
514 
515// Load era-relevant suggestions for the current objective when the board appears.
516onMounted(() => gameStore.loadSuggestions())
517</script>
518 
519<style scoped>
520/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
521 pulse, stilled under reduced motion. Never a clickable fake. */
522.skeleton-line {
523 display: block;
524 height: 10px;
525 border-radius: 2px;
526 background: var(--rv-tint);
527 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
529@keyframes skeleton-pulse {
530 0%, 100% { opacity: .55; }
531 50% { opacity: 1; }
533@media (prefers-reduced-motion: reduce) {
534 .skeleton-line { animation: none; }
536 
537/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
538.search-pop {
539 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
541</style>

Proving the triggers without sound

The trigger logic is the thing worth testing, and it is tested with audio mocked. A hand-rolled FakeAudioContext records the nodes a cue creates and how they connect — enough to assert that a cue scheduled a graph, that a muted play creates nothing at all, and that the master bus carries the chosen volume. It starts suspended, like a real browser context, so the unlock path is exercised too.

The AudioContext double: it tracks every node created and every connection.

tests/unit/support/fake-audio.ts · 139 lines
tests/unit/support/fake-audio.ts139 lines · TypeScript
⋯ 91 lines hidden (lines 1–91)
1/**
2 * A hand-rolled AudioContext double for unit tests — node/happy-dom ship no Web Audio.
3 * It records the nodes created and how they connect: enough to assert that a cue
4 * scheduled a graph reaching the output, that a muted play creates nothing at all, and
5 * that the master bus carries the chosen volume. Same stance as the useCoachAnchor
6 * test's getBoundingClientRect stub: fake exactly the surface under test, nothing more.
7 */
8 
9/** Internal — the node classes expose it via their public params; specs read `.value`. */
10class FakeParam {
11 value = 0
12 setValueAtTime(value: number, _time?: number): this {
13 this.value = value
14 return this
15 }
16 linearRampToValueAtTime(value: number, _time?: number): this {
17 this.value = value
18 return this
19 }
20 exponentialRampToValueAtTime(value: number, _time?: number): this {
21 this.value = value
22 return this
23 }
24 cancelScheduledValues(_time?: number): this {
25 return this
26 }
28 
29export type FakeKind = 'gain' | 'oscillator' | 'filter' | 'bufferSource' | 'node'
30 
31export class FakeNode {
32 readonly kind: FakeKind = 'node'
33 connections: FakeNode[] = []
34 connect(target: FakeNode): FakeNode {
35 this.connections.push(target)
36 return target
37 }
38 disconnect(): void {}
40 
41export class FakeGain extends FakeNode {
42 override readonly kind: FakeKind = 'gain'
43 gain = new FakeParam()
45 
46export class FakeOscillator extends FakeNode {
47 override readonly kind: FakeKind = 'oscillator'
48 type = 'sine'
49 frequency = new FakeParam()
50 detune = new FakeParam()
51 started = false
52 stopped = false
53 start(_when?: number): void {
54 this.started = true
55 }
56 stop(_when?: number): void {
57 this.stopped = true
58 }
60 
61export class FakeFilter extends FakeNode {
62 override readonly kind: FakeKind = 'filter'
63 type = 'allpass'
64 frequency = new FakeParam()
65 Q = new FakeParam()
67 
68export class FakeBufferSource extends FakeNode {
69 override readonly kind: FakeKind = 'bufferSource'
70 buffer: FakeBuffer | null = null
71 loop = false
72 started = false
73 stopped = false
74 start(_when?: number): void {
75 this.started = true
76 }
77 stop(_when?: number): void {
78 this.stopped = true
79 }
81 
82export class FakeBuffer {
83 private data: Float32Array
84 constructor(_channels: number, length: number, public sampleRate: number) {
85 this.data = new Float32Array(Math.max(1, length))
86 }
87 getChannelData(_channel?: number): Float32Array {
88 return this.data
89 }
91 
92export class FakeAudioContext {
93 /** Every context the engine creates lands here — reset it per test. */
94 static instances: FakeAudioContext[] = []
95 // Browsers hand back a SUSPENDED context until a gesture resumes it — modelling
96 // that lets the engine's unlock path (resume on first play) be asserted.
97 state: 'suspended' | 'running' | 'closed' = 'suspended'
98 currentTime = 0
99 sampleRate = 44100
100 destination = new FakeNode()
101 created: FakeNode[] = []
102 resumed = 0
103 closedCount = 0
104 
105 constructor() {
106 FakeAudioContext.instances.push(this)
107 }
108 
109 private track<T extends FakeNode>(node: T): T {
110 this.created.push(node)
111 return node
112 }
113 
114 createGain(): FakeGain {
115 return this.track(new FakeGain())
116 }
117 createOscillator(): FakeOscillator {
118 return this.track(new FakeOscillator())
119 }
120 createBiquadFilter(): FakeFilter {
121 return this.track(new FakeFilter())
122 }
123 createBufferSource(): FakeBufferSource {
124 return this.track(new FakeBufferSource())
125 }
126 createBuffer(channels: number, length: number, sampleRate: number): FakeBuffer {
127 return new FakeBuffer(channels, length, sampleRate)
128 }
129 resume(): Promise<void> {
130 this.resumed++
131 this.state = 'running'
132 return Promise.resolve()
133 }
⋯ 6 lines hidden (lines 134–139)
134 close(): Promise<void> {
135 this.closedCount++
136 this.state = 'closed'
137 return Promise.resolve()
138 }

The wiring is tested by running it in a bare effectScope with a real Pinia store and a spy engine — no DOM, no audio. Drive a store transition, flush a tick, assert the right cue fired. The two load-bearing cases: a new roll lands with its thunk, tiered sting, and grade note; and a reset never sounds like a loss.

Inject a spy engine into an effect scope; assert event → cue; assert a reset stays silent.

tests/unit/composables/useGameSoundscape.spec.ts · 169 lines
tests/unit/composables/useGameSoundscape.spec.ts169 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { setActivePinia, createPinia } from 'pinia'
3import { effectScope, nextTick, type EffectScope } from 'vue'
4import { useGameSoundscape } from '../../../composables/useGameSoundscape'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7import { listCuratedObjectives } from '../../../server/utils/objectives'
8 
9function fakeEngine() {
10 return {
11 play: vi.fn(),
12 startRattle: vi.fn(),
13 stopRattle: vi.fn(),
14 unlock: vi.fn()
15 }
17 
18describe('useGameSoundscape wiring', () => {
19 let game: ReturnType<typeof useGameStore>
20 let engine: ReturnType<typeof fakeEngine>
21 let scope: EffectScope
22 
23 beforeEach(() => {
24 setActivePinia(createPinia())
25 game = useGameStore()
26 engine = fakeEngine()
27 scope = effectScope()
28 scope.run(() => useGameSoundscape({ gameStore: game, engine }))
⋯ 20 lines hidden (lines 29–48)
29 })
30 
31 afterEach(() => {
32 scope.stop()
33 })
34 
35 const playedCues = () => engine.play.mock.calls.map((c) => c[0] as string)
36 const startRun = async () => {
37 game.currentObjective = listCuratedObjectives()[0]
38 await nextTick()
39 engine.play.mockClear()
40 }
41 
42 it('whooshes the dispatch and starts the rattle on the throw', async () => {
43 game.setLoading(true)
44 await nextTick()
45 expect(playedCues()).toContain('dispatch')
46 expect(engine.startRattle).toHaveBeenCalled()
47 })
48 
49 it('lands the die: thunk + tiered outcome sting + craft grace note', async () => {
50 game.setLoading(true)
51 await nextTick()
52 game.addAIMessageWithData({
53 text: 'It is done.',
54 sender: 'ai',
55 diceRoll: 20,
56 diceOutcome: DiceOutcome.CRITICAL_SUCCESS,
57 craft: 'masterful'
58 })
59 game.setLoading(false)
60 await nextTick()
61 expect(engine.stopRattle).toHaveBeenCalled()
62 const cues = playedCues()
63 expect(cues).toContain('diceLand')
64 expect(cues).toContain('outcomeCritSuccess')
65 expect(cues).toContain('craftMasterful')
66 })
⋯ 62 lines hidden (lines 67–128)
67 
68 it('a refunded send (loading off, no new roll) stops the rattle but never replays a land', async () => {
69 game.setLoading(true)
70 await nextTick()
71 game.setLoading(false) // no AI message added — the send was refunded
72 await nextTick()
73 expect(engine.stopRattle).toHaveBeenCalled()
74 expect(playedCues()).not.toContain('diceLand')
75 })
76 
77 it('sounds a gain vs a loss only inside a live run', async () => {
78 await startRun()
79 game.objectiveProgress = 12
80 await nextTick()
81 expect(playedCues()).toContain('progressGain')
82 game.objectiveProgress = 4
83 await nextTick()
84 expect(playedCues()).toContain('progressLoss')
85 })
86 
87 it('climbs and shatters momentum, pitched by height', async () => {
88 await startRun()
89 game.momentum = 2
90 await nextTick()
91 expect(engine.play).toHaveBeenCalledWith('momentumBuild', { intensity: 2 })
92 game.momentum = 0
93 await nextTick()
94 expect(engine.play).toHaveBeenCalledWith('momentumShatter', { intensity: 1.3 })
95 })
96 
97 it('stamps a new ledger node (growth only)', async () => {
98 engine.play.mockClear()
99 game.addTimelineEvent({
100 figureName: 'X', era: 'e', headline: 'h', detail: 'd',
101 diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 12
102 })
103 await nextTick()
104 expect(playedCues()).toContain('ledgerStamp')
105 })
106 
107 it('turns a page on a chronicle rewrite, strikes the verdict, and denies', async () => {
108 game.chronicle = { title: 't', paragraphs: ['p'] }
109 await nextTick()
110 expect(playedCues()).toContain('chroniclePage')
111 
112 game.gameStatus = 'victory'
113 await nextTick()
114 expect(playedCues()).toContain('victory')
115 
116 game.moderationNotice = 'blocked content'
117 await nextTick()
118 expect(playedCues()).toContain('blocked')
119 
120 game.error = 'infra hiccup'
121 await nextTick()
122 expect(playedCues()).toContain('denied')
123 
124 game.isRateLimited = true
125 await nextTick()
126 expect(playedCues().filter((c) => c === 'denied').length).toBeGreaterThanOrEqual(2)
127 })
128 
129 it('a reset never sounds like a loss — the stale/teardown guard', async () => {
130 game.currentObjective = listCuratedObjectives()[0]
131 game.objectiveProgress = 50
132 game.momentum = 3
133 game.addTimelineEvent({
134 figureName: 'X', era: 'e', headline: 'h', detail: 'd',
135 diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 12
136 })
137 await nextTick()
138 engine.play.mockClear()
139 
140 game.resetGame()
141 await nextTick()
142 
143 const cues = playedCues()
144 expect(cues).not.toContain('progressLoss')
145 expect(cues).not.toContain('momentumShatter')
146 expect(cues).not.toContain('defeat')
147 expect(cues).not.toContain('victory')
148 })
⋯ 21 lines hidden (lines 149–169)
149 
150 it('re-arms the land dedupe after a reset so a new run lands again', async () => {
151 // First run: land a roll.
152 game.setLoading(true)
153 await nextTick()
154 game.addAIMessageWithData({ text: 'a', sender: 'ai', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS })
155 game.setLoading(false)
156 await nextTick()
157 game.resetGame()
158 await nextTick()
159 engine.play.mockClear()
160 
161 // New run: a fresh land must still sound (the index dedupe re-armed).
162 game.setLoading(true)
163 await nextTick()
164 game.addAIMessageWithData({ text: 'b', sender: 'ai', diceRoll: 5, diceOutcome: DiceOutcome.FAILURE })
165 game.setLoading(false)
166 await nextTick()
167 expect(playedCues()).toContain('diceLand')
168 })
169})