What changed, and why

Playtesters kept seeing the same names in the figure-suggestion list every turn, including people they had just messaged. The list never moved as a run went on, so it read as dead chrome instead of a live "who to reach next".

The fix is small because the data was already there. The picker already keeps a contacted list of everyone messaged this run; the suggestion list just never looked at it. This change derives the displayed suggestions by removing anyone already contacted, and adds a short note for the case where that empties the list. It is UI-only: no store action, API route, or AI prompt is touched.

The filter: suggestions become "who to try next"

contacted mirrors the store's record of every figure messaged this run. displaySuggestions now subtracts those names from its base list — the era-aware suggestions, or the famous-names fallback (LOCAL_DEFAULT) when the suggester returned nothing. A Set keeps the lookup cheap.

contacted (224) is the store's contacted-figures list; displaySuggestions (318) filters the base by it.

components/FigurePicker.vue · 554 lines
components/FigurePicker.vue554 lines · Vue
⋯ 223 lines hidden (lines 1–223)
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 <!-- Every suggested figure has already been contacted: the list is "who to try
201 next", so when it empties say so and point back to free typing, rather than
202 leaving a dead header over nothing (issue #130). -->
203 <p v-if="!displaySuggestions.length" data-testid="suggestions-exhausted" class="text-[11px] rv-faint italic px-0.5">
204 You've reached everyone suggested here — type any name to keep going.
205 </p>
206 </div>
207 </div>
208 </div>
209</template>
210 
211<script setup lang="ts">
212/**
213 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
214 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
215 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
216 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
217 */
218import { computed, watch, onMounted, onUnmounted } from 'vue'
219import { useGameStore } from '~/stores/game'
220import { useSoundscape } from '~/composables/useSoundscape'
221import { formatContactMoment, MONTH_NAMES, MONTH_MAX_DAY } from '~/utils/contact-moment'
222import type { FigureSuggestion } from '~/server/utils/figure-suggester'
223 
224const figure = defineModel<string>({ default: '' })
⋯ 90 lines hidden (lines 225–314)
225defineProps<{ disabled?: boolean }>()
226 
227const gameStore = useGameStore()
228// A quiet papery tick when a figure is deliberately picked (issue #92, UI micro).
229const soundscape = useSoundscape()
230const contacted = computed(() => gameStore.figures)
231const figureSuggestions = computed(() => gameStore.figureSuggestions)
232const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
233const suggestionsNotice = computed(() => gameStore.suggestionsNotice)
234const grounding = computed(() => gameStore.figureGrounding)
235const groundingLoading = computed(() => gameStore.groundingLoading)
236const contactWhen = computed(() => gameStore.contactWhen)
237const contactAge = computed(() => gameStore.contactAge)
238const activeName = computed(() => figure.value.trim())
239 
240const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
241// Deceased-only floor (#72): a resolved figure with no confirmed death is living
242// (or undatable) and can't be reached — show a block instead of the when-control.
243const contactBlocked = computed(() => !!(grounding.value?.resolved && !grounding.value.died))
244const contactBlockReason = computed(() => {
245 const g = grounding.value
246 if (!contactBlocked.value || !g) return ''
247 return g.born
248 ? `${g.name} is still living — Everwhen only reaches figures from history.`
249 : `${g.name} couldn't be dated — Everwhen can only reach figures it can place in history.`
250})
251const lifespan = computed(() => {
252 const g = grounding.value
253 if (!g?.born) return ''
254 if (g.died) return `${g.born.display}${g.died.display}`
255 return g.living ? `${g.born.display} – present` : g.born.display
256})
257const range = computed(() => ({
258 min: grounding.value?.born?.signed ?? 0,
259 max: grounding.value?.died?.signed ?? new Date().getFullYear()
260}))
261const contactMoment = computed(() => gameStore.contactMoment)
262const whenDisplay = computed(() => (contactWhen.value != null ? formatContactMoment(contactWhen.value, contactMoment.value) : ''))
263// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
264// not the raw signed integer the value attribute carries.
265const whenValueText = computed(() =>
266 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
268 
269function onWhenInput(e: Event) {
270 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
272 
273// --- Pin the moment (issue #32): month chips + optional day, year-canonical ---
274const momentOpen = ref(false)
275const maxDay = computed(() => (contactMoment.value ? MONTH_MAX_DAY[contactMoment.value.month - 1] : 31))
276function pickMonth(month: number) {
277 const current = gameStore.contactMoment
278 if (current?.month === month) return
279 // Switching months keeps the day where it stays valid, clamps where it doesn't.
280 const day = current?.day ? Math.min(current.day, MONTH_MAX_DAY[month - 1]) : undefined
281 gameStore.setContactMoment(day ? { month, day } : { month })
283function onDayInput(e: Event) {
284 const current = gameStore.contactMoment
285 if (!current) return
286 const raw = Number((e.target as HTMLInputElement).value)
287 if (!Number.isInteger(raw) || raw < 1) {
288 gameStore.setContactMoment({ month: current.month })
289 return
290 }
291 gameStore.setContactMoment({ month: current.month, day: Math.min(raw, MONTH_MAX_DAY[current.month - 1]) })
293function unpinMoment() {
294 gameStore.setContactMoment(null)
295 momentOpen.value = false
297 
298// The Archive: study the grounded figure in-game, at the chosen moment.
299const figureStudy = computed(() => gameStore.figureStudy)
300const studyLoading = computed(() => gameStore.studyLoading)
301const studyNotice = computed(() => gameStore.studyNotice)
302const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
303const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
304const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
305const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
306function studyThem() { gameStore.studyActiveFigure() }
307 
308const LOCAL_DEFAULT: FigureSuggestion[] = [
309 { name: 'Cleopatra', reason: '' },
310 { name: 'Nikola Tesla', reason: '' },
311 { name: 'Genghis Khan', reason: '' },
312 { name: 'Marie Curie', reason: '' },
313 { name: 'Leonardo da Vinci', reason: '' }
315// The suggestion list is "who to try next": figures already messaged this run drop
316// off it, so it stops re-proposing people you just contacted (issue #130). They stay
317// one tap away in the contacts row above. Exact-name match, as registerFigure dedups.
318const displaySuggestions = computed(() => {
319 const base = figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT
320 const contactedNames = new Set(contacted.value.map(f => f.name))
321 return base.filter(s => !contactedNames.has(s.name))
322})
⋯ 232 lines hidden (lines 323–554)
323// Pending = in flight, OR simply not yet asked for this objective — the latter
324// covers the first painted frame (loadSuggestions fires onMounted, after render),
325// which would otherwise flash the clickable famous-names fallback for one frame.
326// The store records suggestionsFor even on failure, so a genuine miss still
327// settles into the honest fallback rather than skeletons forever.
328const suggestionsPending = computed(() =>
329 !figureSuggestions.value.length && (
330 suggestionsLoading.value ||
331 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
332 )
334const suggestionsLabel = computed(() =>
335 suggestionsPending.value
336 ? 'finding figures who matter here…'
337 : figureSuggestions.value.length
338 ? 'figures who might matter here'
339 : 'some famous names to start with'
341 
342function select(name: string) {
343 choose(name)
345 
346// --- Name autocomplete (a combobox over Wikipedia title search) ---
347interface SearchResult { name: string; description?: string; thumbnail?: string }
348const searchBoxRef = ref<HTMLElement | null>(null)
349const searchInputRef = ref<HTMLInputElement | null>(null)
350const searchResults = ref<SearchResult[]>([])
351const searchOpen = ref(false)
352// Sustained weather (rate-limit blips) finally gave up with nothing to show: a
353// visible, retryable hint takes the popup so an outage degrades honestly instead
354// of an empty box that reads as "no matches" (issue #58).
355const searchUnavailable = ref(false)
356const activeIndex = ref(-1)
357// The listbox is the result popup; the unavailable hint is a separate, non-listbox
358// surface. aria-expanded / aria-controls track only this.
359const listboxOpen = computed(() => searchOpen.value && searchResults.value.length > 0)
360let searchDebounce: ReturnType<typeof setTimeout> | null = null
361let searchSeq = 0
362// Backoff for transient (rate-limit) misses: spaced retries before giving up, so a
363// throttled prefix like "Leonardo" gets time to clear rather than vanishing.
364const SEARCH_RETRY_DELAYS_MS = [800, 1600]
365// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
366// the model change it causes must not reopen the dropdown over the fresh dossier.
367let lastChosen = ''
368 
369/** Closing also INVALIDATES any response still in flight: bumping the seq is what
370 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
371 * dossier, an emptied input, or a disabled one mid-send. It also cancels a pending
372 * backoff retry — otherwise a timer armed before the dismissal would later fire and
373 * pop the dropdown (or the unavailable hint) back up unprompted. */
374function closeSearch() {
375 if (searchDebounce) clearTimeout(searchDebounce)
376 searchDebounce = null
377 searchSeq++
378 searchResults.value = []
379 searchOpen.value = false
380 searchUnavailable.value = false
381 activeIndex.value = -1
383 
384/** Deliberate selection from any surface: fill the input and stand down. */
385function choose(name: string) {
386 lastChosen = name
387 figure.value = name
388 closeSearch()
389 soundscape.play('uiTick')
391 
392/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
393async function runSearch(q: string, seq: number, attempt = 0) {
394 try {
395 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[]; transient?: boolean }
396 if (seq !== searchSeq) return // closed, or a newer keystroke took over
397 const results = res?.results ?? []
398 // Weather, not absence: an empty answer the server marks transient must not
399 // read as "no matches" — keep whatever is showing and retry with backoff.
400 // (A new keystroke clears the timer; the seq guard drops a stale landing.)
401 if (!results.length && res?.transient) {
402 const delay = SEARCH_RETRY_DELAYS_MS[attempt]
403 if (delay != null) {
404 searchDebounce = setTimeout(() => { void runSearch(q, seq, attempt + 1) }, delay)
405 } else if (!searchResults.value.length) {
406 // Out of retries with nothing to fall back on: say so plainly and
407 // offer a retry, rather than an empty box that looks like "no matches".
408 searchUnavailable.value = true
409 searchOpen.value = true
410 activeIndex.value = -1
411 }
412 return
413 }
414 searchUnavailable.value = false
415 searchResults.value = results
416 searchOpen.value = results.length > 0
417 activeIndex.value = -1
418 } catch {
419 if (seq === searchSeq) closeSearch()
420 }
422 
423/** Manual retry from the "unavailable" hint — refire the search for the current text.
424 * Returns focus to the input first: clearing the hint unmounts the Retry button, and
425 * a keyboard user activating it would otherwise have focus fall to <body>. */
426function retrySearch() {
427 const q = (figure.value || '').trim()
428 if (q.length < 2) return
429 searchInputRef.value?.focus()
430 searchUnavailable.value = false
431 void runSearch(q, ++searchSeq)
433 
434watch(figure, (name) => {
435 if (searchDebounce) clearTimeout(searchDebounce)
436 const q = (name || '').trim()
437 if (q.length < 2 || q === lastChosen) {
438 closeSearch()
439 // One-shot suppression: it exists to swallow the single model echo a
440 // choice causes. A later deliberate retype of the same name searches again.
441 if (q === lastChosen) lastChosen = ''
442 return
443 }
444 lastChosen = ''
445 searchUnavailable.value = false // a fresh keystroke retires any prior outage hint
446 const seq = ++searchSeq
447 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
448})
449 
450function onSearchKeydown(e: KeyboardEvent) {
451 if (e.isComposing) return // IME candidate navigation owns these keys
452 // Escape always dismisses: it must clear a populated listbox OR the unavailable
453 // hint, and — even when nothing is on screen yet — cancel a pending backoff retry
454 // and invalidate any in-flight fetch (closeSearch clears the timer and bumps the
455 // seq). Handled before the guard below, which only covers a populated listbox.
456 if (e.key === 'Escape') {
457 closeSearch()
458 return
459 }
460 if (!searchOpen.value || !searchResults.value.length) {
461 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
462 // (the only recovery after Escape that doesn't require editing the text).
463 if (e.key === 'ArrowDown') {
464 const q = (figure.value || '').trim()
465 if (q.length >= 2) {
466 e.preventDefault()
467 void runSearch(q, ++searchSeq)
468 }
469 }
470 return
471 }
472 if (e.key === 'ArrowDown') {
473 e.preventDefault()
474 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
475 } else if (e.key === 'ArrowUp') {
476 e.preventDefault()
477 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
478 } else if (e.key === 'Enter') {
479 if (activeIndex.value >= 0) {
480 e.preventDefault()
481 choose(searchResults.value[activeIndex.value].name)
482 }
483 }
484 // Escape is handled up top (it must also dismiss the unavailable hint, where
485 // searchResults is empty and this block is skipped).
487 
488/** Close when focus genuinely leaves the combobox (not when it moves within it). */
489function onSearchFocusOut(e: FocusEvent) {
490 const next = e.relatedTarget as Node | null
491 if (next && searchBoxRef.value?.contains(next)) return
492 closeSearch()
494 
495/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
496const searchStatus = computed(() =>
497 searchUnavailable.value
498 ? 'The record is unreachable right now — press the down arrow or Retry to search again.'
499 : listboxOpen.value
500 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
501 : ''
503 
504function chipClass(name: string) {
505 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
507function suggestionClass(name: string) {
508 return name === figure.value ? 'rv-tint' : 'rv-hover'
510 
511// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
512// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
513// fired during the debounce/fetch window must go out clean (free-form), never
514// wearing the old figure's facts, year, or liveness gate.
515let debounce: ReturnType<typeof setTimeout> | null = null
516watch(figure, (name) => {
517 if (debounce) clearTimeout(debounce)
518 gameStore.clearGrounding()
519 const trimmed = (name || '').trim()
520 if (!trimmed) return
521 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
522})
523onUnmounted(() => {
524 if (debounce) clearTimeout(debounce)
525 if (searchDebounce) clearTimeout(searchDebounce)
526})
527 
528// Load era-relevant suggestions for the current objective when the board appears.
529onMounted(() => gameStore.loadSuggestions())
530</script>
531 
532<style scoped>
533/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
534 pulse, stilled under reduced motion. Never a clickable fake. */
535.skeleton-line {
536 display: block;
537 height: 10px;
538 border-radius: 2px;
539 background: var(--rv-tint);
540 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
542@keyframes skeleton-pulse {
543 0%, 100% { opacity: .55; }
544 50% { opacity: 1; }
546@media (prefers-reduced-motion: reduce) {
547 .skeleton-line { animation: none; }
549 
550/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
551.search-pop {
552 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
554</style>

When the list empties

Filtering can empty the list: contact every suggested figure and nothing is left. Rather than leave the section's header floating over nothing, a one-line note takes the list's place and points the player back to free typing (anyone is still reachable by name). It renders only when the filtered list is empty, and it lives in the branch that already excludes the loading-skeleton state, so it never shows while suggestions are still being fetched.

The note follows the suggestion buttons, gated on an empty displaySuggestions.

components/FigurePicker.vue · 554 lines
components/FigurePicker.vue554 lines · Vue
⋯ 192 lines hidden (lines 1–192)
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 <!-- Every suggested figure has already been contacted: the list is "who to try
201 next", so when it empties say so and point back to free typing, rather than
202 leaving a dead header over nothing (issue #130). -->
203 <p v-if="!displaySuggestions.length" data-testid="suggestions-exhausted" class="text-[11px] rv-faint italic px-0.5">
204 You've reached everyone suggested here — type any name to keep going.
205 </p>
206 </div>
⋯ 348 lines hidden (lines 207–554)
207 </div>
208 </div>
209</template>
210 
211<script setup lang="ts">
212/**
213 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
214 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
215 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
216 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
217 */
218import { computed, watch, onMounted, onUnmounted } from 'vue'
219import { useGameStore } from '~/stores/game'
220import { useSoundscape } from '~/composables/useSoundscape'
221import { formatContactMoment, MONTH_NAMES, MONTH_MAX_DAY } from '~/utils/contact-moment'
222import type { FigureSuggestion } from '~/server/utils/figure-suggester'
223 
224const figure = defineModel<string>({ default: '' })
225defineProps<{ disabled?: boolean }>()
226 
227const gameStore = useGameStore()
228// A quiet papery tick when a figure is deliberately picked (issue #92, UI micro).
229const soundscape = useSoundscape()
230const contacted = computed(() => gameStore.figures)
231const figureSuggestions = computed(() => gameStore.figureSuggestions)
232const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
233const suggestionsNotice = computed(() => gameStore.suggestionsNotice)
234const grounding = computed(() => gameStore.figureGrounding)
235const groundingLoading = computed(() => gameStore.groundingLoading)
236const contactWhen = computed(() => gameStore.contactWhen)
237const contactAge = computed(() => gameStore.contactAge)
238const activeName = computed(() => figure.value.trim())
239 
240const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
241// Deceased-only floor (#72): a resolved figure with no confirmed death is living
242// (or undatable) and can't be reached — show a block instead of the when-control.
243const contactBlocked = computed(() => !!(grounding.value?.resolved && !grounding.value.died))
244const contactBlockReason = computed(() => {
245 const g = grounding.value
246 if (!contactBlocked.value || !g) return ''
247 return g.born
248 ? `${g.name} is still living — Everwhen only reaches figures from history.`
249 : `${g.name} couldn't be dated — Everwhen can only reach figures it can place in history.`
250})
251const lifespan = computed(() => {
252 const g = grounding.value
253 if (!g?.born) return ''
254 if (g.died) return `${g.born.display}${g.died.display}`
255 return g.living ? `${g.born.display} – present` : g.born.display
256})
257const range = computed(() => ({
258 min: grounding.value?.born?.signed ?? 0,
259 max: grounding.value?.died?.signed ?? new Date().getFullYear()
260}))
261const contactMoment = computed(() => gameStore.contactMoment)
262const whenDisplay = computed(() => (contactWhen.value != null ? formatContactMoment(contactWhen.value, contactMoment.value) : ''))
263// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
264// not the raw signed integer the value attribute carries.
265const whenValueText = computed(() =>
266 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
268 
269function onWhenInput(e: Event) {
270 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
272 
273// --- Pin the moment (issue #32): month chips + optional day, year-canonical ---
274const momentOpen = ref(false)
275const maxDay = computed(() => (contactMoment.value ? MONTH_MAX_DAY[contactMoment.value.month - 1] : 31))
276function pickMonth(month: number) {
277 const current = gameStore.contactMoment
278 if (current?.month === month) return
279 // Switching months keeps the day where it stays valid, clamps where it doesn't.
280 const day = current?.day ? Math.min(current.day, MONTH_MAX_DAY[month - 1]) : undefined
281 gameStore.setContactMoment(day ? { month, day } : { month })
283function onDayInput(e: Event) {
284 const current = gameStore.contactMoment
285 if (!current) return
286 const raw = Number((e.target as HTMLInputElement).value)
287 if (!Number.isInteger(raw) || raw < 1) {
288 gameStore.setContactMoment({ month: current.month })
289 return
290 }
291 gameStore.setContactMoment({ month: current.month, day: Math.min(raw, MONTH_MAX_DAY[current.month - 1]) })
293function unpinMoment() {
294 gameStore.setContactMoment(null)
295 momentOpen.value = false
297 
298// The Archive: study the grounded figure in-game, at the chosen moment.
299const figureStudy = computed(() => gameStore.figureStudy)
300const studyLoading = computed(() => gameStore.studyLoading)
301const studyNotice = computed(() => gameStore.studyNotice)
302const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
303const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
304const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
305const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
306function studyThem() { gameStore.studyActiveFigure() }
307 
308const LOCAL_DEFAULT: FigureSuggestion[] = [
309 { name: 'Cleopatra', reason: '' },
310 { name: 'Nikola Tesla', reason: '' },
311 { name: 'Genghis Khan', reason: '' },
312 { name: 'Marie Curie', reason: '' },
313 { name: 'Leonardo da Vinci', reason: '' }
315// The suggestion list is "who to try next": figures already messaged this run drop
316// off it, so it stops re-proposing people you just contacted (issue #130). They stay
317// one tap away in the contacts row above. Exact-name match, as registerFigure dedups.
318const displaySuggestions = computed(() => {
319 const base = figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT
320 const contactedNames = new Set(contacted.value.map(f => f.name))
321 return base.filter(s => !contactedNames.has(s.name))
322})
323// Pending = in flight, OR simply not yet asked for this objective — the latter
324// covers the first painted frame (loadSuggestions fires onMounted, after render),
325// which would otherwise flash the clickable famous-names fallback for one frame.
326// The store records suggestionsFor even on failure, so a genuine miss still
327// settles into the honest fallback rather than skeletons forever.
328const suggestionsPending = computed(() =>
329 !figureSuggestions.value.length && (
330 suggestionsLoading.value ||
331 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
332 )
334const suggestionsLabel = computed(() =>
335 suggestionsPending.value
336 ? 'finding figures who matter here…'
337 : figureSuggestions.value.length
338 ? 'figures who might matter here'
339 : 'some famous names to start with'
341 
342function select(name: string) {
343 choose(name)
345 
346// --- Name autocomplete (a combobox over Wikipedia title search) ---
347interface SearchResult { name: string; description?: string; thumbnail?: string }
348const searchBoxRef = ref<HTMLElement | null>(null)
349const searchInputRef = ref<HTMLInputElement | null>(null)
350const searchResults = ref<SearchResult[]>([])
351const searchOpen = ref(false)
352// Sustained weather (rate-limit blips) finally gave up with nothing to show: a
353// visible, retryable hint takes the popup so an outage degrades honestly instead
354// of an empty box that reads as "no matches" (issue #58).
355const searchUnavailable = ref(false)
356const activeIndex = ref(-1)
357// The listbox is the result popup; the unavailable hint is a separate, non-listbox
358// surface. aria-expanded / aria-controls track only this.
359const listboxOpen = computed(() => searchOpen.value && searchResults.value.length > 0)
360let searchDebounce: ReturnType<typeof setTimeout> | null = null
361let searchSeq = 0
362// Backoff for transient (rate-limit) misses: spaced retries before giving up, so a
363// throttled prefix like "Leonardo" gets time to clear rather than vanishing.
364const SEARCH_RETRY_DELAYS_MS = [800, 1600]
365// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
366// the model change it causes must not reopen the dropdown over the fresh dossier.
367let lastChosen = ''
368 
369/** Closing also INVALIDATES any response still in flight: bumping the seq is what
370 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
371 * dossier, an emptied input, or a disabled one mid-send. It also cancels a pending
372 * backoff retry — otherwise a timer armed before the dismissal would later fire and
373 * pop the dropdown (or the unavailable hint) back up unprompted. */
374function closeSearch() {
375 if (searchDebounce) clearTimeout(searchDebounce)
376 searchDebounce = null
377 searchSeq++
378 searchResults.value = []
379 searchOpen.value = false
380 searchUnavailable.value = false
381 activeIndex.value = -1
383 
384/** Deliberate selection from any surface: fill the input and stand down. */
385function choose(name: string) {
386 lastChosen = name
387 figure.value = name
388 closeSearch()
389 soundscape.play('uiTick')
391 
392/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
393async function runSearch(q: string, seq: number, attempt = 0) {
394 try {
395 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[]; transient?: boolean }
396 if (seq !== searchSeq) return // closed, or a newer keystroke took over
397 const results = res?.results ?? []
398 // Weather, not absence: an empty answer the server marks transient must not
399 // read as "no matches" — keep whatever is showing and retry with backoff.
400 // (A new keystroke clears the timer; the seq guard drops a stale landing.)
401 if (!results.length && res?.transient) {
402 const delay = SEARCH_RETRY_DELAYS_MS[attempt]
403 if (delay != null) {
404 searchDebounce = setTimeout(() => { void runSearch(q, seq, attempt + 1) }, delay)
405 } else if (!searchResults.value.length) {
406 // Out of retries with nothing to fall back on: say so plainly and
407 // offer a retry, rather than an empty box that looks like "no matches".
408 searchUnavailable.value = true
409 searchOpen.value = true
410 activeIndex.value = -1
411 }
412 return
413 }
414 searchUnavailable.value = false
415 searchResults.value = results
416 searchOpen.value = results.length > 0
417 activeIndex.value = -1
418 } catch {
419 if (seq === searchSeq) closeSearch()
420 }
422 
423/** Manual retry from the "unavailable" hint — refire the search for the current text.
424 * Returns focus to the input first: clearing the hint unmounts the Retry button, and
425 * a keyboard user activating it would otherwise have focus fall to <body>. */
426function retrySearch() {
427 const q = (figure.value || '').trim()
428 if (q.length < 2) return
429 searchInputRef.value?.focus()
430 searchUnavailable.value = false
431 void runSearch(q, ++searchSeq)
433 
434watch(figure, (name) => {
435 if (searchDebounce) clearTimeout(searchDebounce)
436 const q = (name || '').trim()
437 if (q.length < 2 || q === lastChosen) {
438 closeSearch()
439 // One-shot suppression: it exists to swallow the single model echo a
440 // choice causes. A later deliberate retype of the same name searches again.
441 if (q === lastChosen) lastChosen = ''
442 return
443 }
444 lastChosen = ''
445 searchUnavailable.value = false // a fresh keystroke retires any prior outage hint
446 const seq = ++searchSeq
447 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
448})
449 
450function onSearchKeydown(e: KeyboardEvent) {
451 if (e.isComposing) return // IME candidate navigation owns these keys
452 // Escape always dismisses: it must clear a populated listbox OR the unavailable
453 // hint, and — even when nothing is on screen yet — cancel a pending backoff retry
454 // and invalidate any in-flight fetch (closeSearch clears the timer and bumps the
455 // seq). Handled before the guard below, which only covers a populated listbox.
456 if (e.key === 'Escape') {
457 closeSearch()
458 return
459 }
460 if (!searchOpen.value || !searchResults.value.length) {
461 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
462 // (the only recovery after Escape that doesn't require editing the text).
463 if (e.key === 'ArrowDown') {
464 const q = (figure.value || '').trim()
465 if (q.length >= 2) {
466 e.preventDefault()
467 void runSearch(q, ++searchSeq)
468 }
469 }
470 return
471 }
472 if (e.key === 'ArrowDown') {
473 e.preventDefault()
474 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
475 } else if (e.key === 'ArrowUp') {
476 e.preventDefault()
477 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
478 } else if (e.key === 'Enter') {
479 if (activeIndex.value >= 0) {
480 e.preventDefault()
481 choose(searchResults.value[activeIndex.value].name)
482 }
483 }
484 // Escape is handled up top (it must also dismiss the unavailable hint, where
485 // searchResults is empty and this block is skipped).
487 
488/** Close when focus genuinely leaves the combobox (not when it moves within it). */
489function onSearchFocusOut(e: FocusEvent) {
490 const next = e.relatedTarget as Node | null
491 if (next && searchBoxRef.value?.contains(next)) return
492 closeSearch()
494 
495/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
496const searchStatus = computed(() =>
497 searchUnavailable.value
498 ? 'The record is unreachable right now — press the down arrow or Retry to search again.'
499 : listboxOpen.value
500 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
501 : ''
503 
504function chipClass(name: string) {
505 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
507function suggestionClass(name: string) {
508 return name === figure.value ? 'rv-tint' : 'rv-hover'
510 
511// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
512// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
513// fired during the debounce/fetch window must go out clean (free-form), never
514// wearing the old figure's facts, year, or liveness gate.
515let debounce: ReturnType<typeof setTimeout> | null = null
516watch(figure, (name) => {
517 if (debounce) clearTimeout(debounce)
518 gameStore.clearGrounding()
519 const trimmed = (name || '').trim()
520 if (!trimmed) return
521 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
522})
523onUnmounted(() => {
524 if (debounce) clearTimeout(debounce)
525 if (searchDebounce) clearTimeout(searchDebounce)
526})
527 
528// Load era-relevant suggestions for the current objective when the board appears.
529onMounted(() => gameStore.loadSuggestions())
530</script>
531 
532<style scoped>
533/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
534 pulse, stilled under reduced motion. Never a clickable fake. */
535.skeleton-line {
536 display: block;
537 height: 10px;
538 border-radius: 2px;
539 background: var(--rv-tint);
540 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
542@keyframes skeleton-pulse {
543 0%, 100% { opacity: .55; }
544 50% { opacity: 1; }
546@media (prefers-reduced-motion: reduce) {
547 .skeleton-line { animation: none; }
549 
550/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
551.search-pop {
552 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
554</style>

The tests

Two unit tests pin the behavior, mounting the real component against a seeded store. The first seeds a contacted figure matching one of two suggestions: it asserts that suggestion is gone, the other stays, and the contacted figure still shows in the contacts row. The second contacts the only suggestion and asserts the chips vanish and the exhausted note appears. Both are discriminating — they fail if the filter or the empty-state note regresses.

The two issue-#130 cases, appended to the existing FigurePicker spec.

tests/unit/components/FigurePicker.spec.ts · 610 lines
tests/unit/components/FigurePicker.spec.ts610 lines · TypeScript
⋯ 575 lines hidden (lines 1–575)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { mount } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import FigurePicker from '../../../components/FigurePicker.vue'
5import { useGameStore } from '../../../stores/game'
6 
7const CLEO = {
8 name: 'Cleopatra',
9 resolved: true,
10 description: 'Pharaoh of Egypt from 51 to 30 BC',
11 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
12 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
13 living: false,
14 thumbnail: 'https://example.org/cleo.jpg',
15 wikiUrl: 'https://en.wikipedia.org/wiki/Cleopatra'
17 
18describe('FigurePicker', () => {
19 let gameStore: ReturnType<typeof useGameStore>
20 let fetchMock: ReturnType<typeof vi.fn>
21 
22 beforeEach(() => {
23 setActivePinia(createPinia())
24 gameStore = useGameStore()
25 // The grounding + autocomplete watches call $fetch; stub it with a handle
26 // the combobox tests can program.
27 fetchMock = vi.fn()
28 vi.stubGlobal('$fetch', fetchMock)
29 })
30 
31 afterEach(() => {
32 vi.unstubAllGlobals()
33 vi.restoreAllMocks()
34 })
35 
36 it('offers cross-era suggestions to start from', () => {
37 const wrapper = mount(FigurePicker)
38 expect(wrapper.find('[data-testid="figure-picker"]').exists()).toBe(true)
39 expect(wrapper.findAll('[data-testid="suggestion-chip"]').length).toBeGreaterThan(0)
40 })
41 
42 it('shows quiet skeletons while suggestions load — never clickable generic defaults', async () => {
43 gameStore.suggestionsLoading = true
44 gameStore.figureSuggestions = []
45 const wrapper = mount(FigurePicker)
46 
47 expect(wrapper.find('[data-testid="suggestion-skeletons"]').exists()).toBe(true)
48 expect(wrapper.findAll('[data-testid="suggestion-chip"]')).toHaveLength(0)
49 expect(wrapper.text()).toContain('finding figures who matter here')
50 })
51 
52 it('labels the famous-names fallback honestly once the agent has come up empty', () => {
53 gameStore.suggestionsLoading = false
54 gameStore.figureSuggestions = []
55 const wrapper = mount(FigurePicker)
56 
57 expect(wrapper.find('[data-testid="suggestion-skeletons"]').exists()).toBe(false)
58 expect(wrapper.findAll('[data-testid="suggestion-chip"]').length).toBeGreaterThan(0)
59 expect(wrapper.text()).toContain('some famous names to start with')
60 expect(wrapper.text()).not.toContain('finding figures who matter here')
61 })
62 
63 describe('name autocomplete (combobox)', () => {
64 const RESULTS = {
65 results: [
66 { name: 'Cleopatra', description: 'Pharaoh of Egypt from 51 to 30 BC', thumbnail: 'https://upload.wikimedia.org/kleo.jpg' },
67 { name: 'Cleopatra (1963 film)', description: '1963 film' }
68 ]
69 }
70 
71 function mountPicker(modelValue = '') {
72 return mount(FigurePicker, { props: { modelValue } })
73 }
74 
75 async function typeAndSettle(wrapper: ReturnType<typeof mountPicker>, text: string) {
76 await wrapper.setProps({ modelValue: text })
77 await vi.advanceTimersByTimeAsync(260) // past the 250ms search debounce
78 await wrapper.vm.$nextTick()
79 }
80 
81 beforeEach(() => {
82 vi.useFakeTimers()
83 })
84 
85 afterEach(() => {
86 vi.useRealTimers()
87 })
88 
89 it('offers matches from the record as you type, with descriptions', async () => {
90 fetchMock.mockResolvedValue(RESULTS)
91 const wrapper = mountPicker()
92 
93 await typeAndSettle(wrapper, 'cleopat')
94 
95 const options = wrapper.findAll('[data-testid="figure-search-option"]')
96 expect(options).toHaveLength(2)
97 expect(options[0].text()).toContain('Cleopatra')
98 expect(options[0].text()).toContain('Pharaoh of Egypt')
99 expect(wrapper.find('#figure-input').attributes('aria-expanded')).toBe('true')
100 wrapper.unmount()
101 })
102 
103 it('arrow keys + Enter choose an option and fill the input', async () => {
104 fetchMock.mockResolvedValue(RESULTS)
105 const wrapper = mountPicker()
106 await typeAndSettle(wrapper, 'cleopat')
107 
108 const input = wrapper.find('#figure-input')
109 await input.trigger('keydown', { key: 'ArrowDown' })
110 await input.trigger('keydown', { key: 'Enter' })
111 
112 const emitted = wrapper.emitted('update:modelValue')
113 expect(emitted?.at(-1)).toEqual(['Cleopatra'])
114 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
115 wrapper.unmount()
116 })
117 
118 it('Escape closes the dropdown without touching the input', async () => {
119 fetchMock.mockResolvedValue(RESULTS)
120 const wrapper = mountPicker()
121 await typeAndSettle(wrapper, 'cleopat')
122 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(true)
123 
124 await wrapper.find('#figure-input').trigger('keydown', { key: 'Escape' })
125 
126 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
127 wrapper.unmount()
128 })
129 
130 it('a response still in flight cannot reopen the dropdown after a choice (close invalidates)', async () => {
131 let release!: (v: unknown) => void
132 fetchMock.mockReturnValue(new Promise((res) => { release = res }))
133 const wrapper = mountPicker()
134 
135 await wrapper.setProps({ modelValue: 'cleopat' })
136 await vi.advanceTimersByTimeAsync(260) // debounce fires — fetch now in flight
137 
138 // The player picks a suggestion chip while the search is still out.
139 await wrapper.find('[data-testid="suggestion-chip"]').trigger('click')
140 
141 release(RESULTS) // the stale response limps home
142 await vi.advanceTimersByTimeAsync(1)
143 await wrapper.vm.$nextTick()
144 
145 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
146 wrapper.unmount()
147 })
148 
149 it('retyping a previously chosen name searches again (suppression is one-shot)', async () => {
150 fetchMock.mockResolvedValue(RESULTS)
151 const wrapper = mountPicker()
152 await typeAndSettle(wrapper, 'cleopat')
153 await wrapper.find('#figure-input').trigger('keydown', { key: 'ArrowDown' })
154 await wrapper.find('#figure-input').trigger('keydown', { key: 'Enter' })
155 await typeAndSettle(wrapper, 'Cleopatra') // the v-model echo — suppressed once
156 
157 await typeAndSettle(wrapper, '') // player clears the field…
158 await typeAndSettle(wrapper, 'Cleopatra') // …and deliberately retypes the name
159 
160 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(true)
161 wrapper.unmount()
162 })
163 
164 it('a transient miss quietly retries once — weather never reads as "no matches"', async () => {
165 // Route by URL: the grounding watcher shares $fetch and must not eat
166 // the queued search answers.
167 let searchCalls = 0
168 fetchMock.mockImplementation((url: unknown) => {
169 if (!String(url).includes('figure-search')) return Promise.resolve({ name: '', resolved: false })
170 searchCalls++
171 return Promise.resolve(searchCalls === 1 ? { results: [], transient: true } : RESULTS)
172 })
173 const wrapper = mountPicker()
174 
175 await typeAndSettle(wrapper, 'alfred nob')
176 // First answer was rate-limit weather: nothing shows yet, no "no matches" state.
177 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
178 
179 // The quiet retry (800ms) lands the real matches without another keystroke.
180 await vi.advanceTimersByTimeAsync(810)
181 await wrapper.vm.$nextTick()
182 expect(wrapper.findAll('[data-testid="figure-search-option"]')).toHaveLength(2)
183 wrapper.unmount()
184 })
185 
186 it('a transient miss never blanks results already on screen', async () => {
187 fetchMock.mockResolvedValueOnce(RESULTS)
188 const wrapper = mountPicker()
189 await typeAndSettle(wrapper, 'cleopat')
190 expect(wrapper.findAll('[data-testid="figure-search-option"]')).toHaveLength(2)
191 
192 // The next keystroke's search hits weather twice (initial + quiet retry).
193 fetchMock.mockResolvedValue({ results: [], transient: true })
194 await typeAndSettle(wrapper, 'cleopatr')
195 await vi.advanceTimersByTimeAsync(810)
196 await wrapper.vm.$nextTick()
197 
198 // The prior list stays up rather than vanishing mid-type.
199 expect(wrapper.findAll('[data-testid="figure-search-option"]')).toHaveLength(2)
200 wrapper.unmount()
201 })
202 
203 it('after retries fail with nothing to show, offers a visible, retryable hint (issue #58)', async () => {
204 // Sustained weather and no prior list to fall back on: the box must say so
205 // (and let the player retry), not sit silently empty like a real "no matches".
206 fetchMock.mockImplementation((url: unknown) =>
207 String(url).includes('figure-search')
208 ? Promise.resolve({ results: [], transient: true })
209 : Promise.resolve({ name: '', resolved: false })
210 )
211 const wrapper = mountPicker()
212 
213 await typeAndSettle(wrapper, 'leonardo') // attempt 0 → transient
214 await vi.advanceTimersByTimeAsync(810) // backoff retry 1 (800ms) → transient
215 await vi.advanceTimersByTimeAsync(1610) // backoff retry 2 (1600ms) → give up
216 await wrapper.vm.$nextTick()
217 
218 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(true)
219 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
220 expect(wrapper.find('#figure-input').attributes('aria-expanded')).toBe('false')
221 // The screen-reader status announces the outage (not a misleading "0 matches").
222 expect(wrapper.find('.sr-only').text()).toContain('unreachable')
223 
224 // Retry recovers the moment the weather clears.
225 fetchMock.mockImplementation((url: unknown) =>
226 String(url).includes('figure-search')
227 ? Promise.resolve(RESULTS)
228 : Promise.resolve({ name: '', resolved: false })
229 )
230 await wrapper.find('[data-testid="figure-search-retry"]').trigger('click')
231 await vi.advanceTimersByTimeAsync(1)
232 await wrapper.vm.$nextTick()
233 
234 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(false)
235 expect(wrapper.findAll('[data-testid="figure-search-option"]')).toHaveLength(2)
236 expect(wrapper.find('.sr-only').text()).not.toContain('unreachable')
237 wrapper.unmount()
238 })
239 
240 it('Escape during a backoff wait cancels the pending retry — nothing pops up later (issue #58)', async () => {
241 // The player types, hits weather, then gives up and presses Escape before
242 // any retry lands. The armed backoff timer must be cancelled, not fire later
243 // and resurrect the dropdown over a field they walked away from.
244 fetchMock.mockImplementation((url: unknown) =>
245 String(url).includes('figure-search')
246 ? Promise.resolve({ results: [], transient: true })
247 : Promise.resolve({ name: '', resolved: false })
248 )
249 const wrapper = mountPicker()
250 await typeAndSettle(wrapper, 'leonardo') // attempt 0 → transient → retry armed at 800ms
251 await wrapper.find('#figure-input').trigger('keydown', { key: 'Escape' })
252 
253 await vi.advanceTimersByTimeAsync(2500) // well past both backoff delays
254 await wrapper.vm.$nextTick()
255 
256 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(false)
257 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
258 expect(wrapper.find('#figure-input').attributes('aria-expanded')).toBe('false')
259 wrapper.unmount()
260 })
261 
262 it('Escape dismisses the visible unavailable hint (issue #58)', async () => {
263 fetchMock.mockImplementation((url: unknown) =>
264 String(url).includes('figure-search')
265 ? Promise.resolve({ results: [], transient: true })
266 : Promise.resolve({ name: '', resolved: false })
267 )
268 const wrapper = mountPicker()
269 await typeAndSettle(wrapper, 'leonardo')
270 await vi.advanceTimersByTimeAsync(810)
271 await vi.advanceTimersByTimeAsync(1610)
272 await wrapper.vm.$nextTick()
273 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(true)
274 
275 await wrapper.find('#figure-input').trigger('keydown', { key: 'Escape' })
276 
277 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(false)
278 expect(wrapper.find('.sr-only').text()).not.toContain('unreachable')
279 wrapper.unmount()
280 })
281 
282 it('keyboard Retry keeps focus in the combobox, not lost to the body (issue #58)', async () => {
283 fetchMock.mockImplementation((url: unknown) =>
284 String(url).includes('figure-search')
285 ? Promise.resolve({ results: [], transient: true })
286 : Promise.resolve({ name: '', resolved: false })
287 )
288 const wrapper = mount(FigurePicker, { props: { modelValue: '' }, attachTo: document.body })
289 await wrapper.setProps({ modelValue: 'leonardo' })
290 await vi.advanceTimersByTimeAsync(260)
291 await vi.advanceTimersByTimeAsync(810)
292 await vi.advanceTimersByTimeAsync(1610)
293 await wrapper.vm.$nextTick()
294 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(true)
295 
296 fetchMock.mockImplementation((url: unknown) =>
297 String(url).includes('figure-search')
298 ? Promise.resolve(RESULTS)
299 : Promise.resolve({ name: '', resolved: false })
300 )
301 await wrapper.find('[data-testid="figure-search-retry"]').trigger('click')
302 // Clearing the hint unmounts the Retry button; focus must land back on the
303 // input, not fall to <body>.
304 expect(document.activeElement).toBe(wrapper.find('#figure-input').element)
305 
306 await vi.advanceTimersByTimeAsync(1)
307 await wrapper.vm.$nextTick()
308 expect(wrapper.findAll('[data-testid="figure-search-option"]')).toHaveLength(2)
309 wrapper.unmount()
310 })
311 
312 it('a fresh keystroke retires the unavailable hint', async () => {
313 fetchMock.mockImplementation((url: unknown) =>
314 String(url).includes('figure-search')
315 ? Promise.resolve({ results: [], transient: true })
316 : Promise.resolve({ name: '', resolved: false })
317 )
318 const wrapper = mountPicker()
319 await typeAndSettle(wrapper, 'leonardo')
320 await vi.advanceTimersByTimeAsync(810)
321 await vi.advanceTimersByTimeAsync(1610)
322 await wrapper.vm.$nextTick()
323 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(true)
324 
325 // Typing more clears the outage hint while the new search is in flight.
326 await wrapper.setProps({ modelValue: 'leonardx' })
327 await wrapper.vm.$nextTick()
328 expect(wrapper.find('[data-testid="figure-search-unavailable"]').exists()).toBe(false)
329 wrapper.unmount()
330 })
331 
332 it('ArrowDown on a closed combobox reopens the search (APG recovery after Escape)', async () => {
333 fetchMock.mockResolvedValue(RESULTS)
334 const wrapper = mountPicker()
335 await typeAndSettle(wrapper, 'cleopat')
336 await wrapper.find('#figure-input').trigger('keydown', { key: 'Escape' })
337 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
338 
339 await wrapper.find('#figure-input').trigger('keydown', { key: 'ArrowDown' })
340 await vi.advanceTimersByTimeAsync(1)
341 await wrapper.vm.$nextTick()
342 
343 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(true)
344 wrapper.unmount()
345 })
346 
347 it('a chosen name does not reopen the dropdown over its own dossier', async () => {
348 fetchMock.mockResolvedValue(RESULTS)
349 const wrapper = mountPicker()
350 await typeAndSettle(wrapper, 'cleopat')
351 
352 const input = wrapper.find('#figure-input')
353 await input.trigger('keydown', { key: 'ArrowDown' })
354 await input.trigger('keydown', { key: 'Enter' })
355 
356 // The parent echoes the chosen value back through the model, as v-model does.
357 await typeAndSettle(wrapper, 'Cleopatra')
358 
359 expect(wrapper.find('[data-testid="figure-search-listbox"]').exists()).toBe(false)
360 wrapper.unmount()
361 })
362 })
363 
364 it('clears the previous dossier the instant the name changes (no stale facts on a fast send)', async () => {
365 gameStore.figureGrounding = CLEO as never
366 gameStore.contactWhen = -40
367 const wrapper = mount(FigurePicker, { props: { modelValue: 'Cleopatra' } })
368 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(true)
369 
370 await wrapper.setProps({ modelValue: 'Marie Curie' })
371 
372 // A send fired during the grounding debounce must go out clean — never
373 // wearing Cleopatra's facts, year, or liveness gate on Curie's name.
374 expect(gameStore.figureGrounding).toBeNull()
375 expect(gameStore.contactWhen).toBeNull()
376 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(false)
377 wrapper.unmount() // clears the pending debounce before $fetch is unstubbed
378 })
379 
380 it('shows a grounded dossier with lifespan and a lifetime-bounded when slider', () => {
381 gameStore.figureGrounding = CLEO as never
382 gameStore.contactWhen = -49
383 const wrapper = mount(FigurePicker)
384 
385 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(true)
386 expect(wrapper.find('[data-testid="dossier-name"]').text()).toBe('Cleopatra')
387 expect(wrapper.find('[data-testid="dossier-lifespan"]').text()).toContain('69 BC – 30 BC')
388 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('49 BC')
389 
390 const slider = wrapper.find('[data-testid="when-slider"]')
391 expect(slider.exists()).toBe(true)
392 expect(slider.attributes('min')).toBe('-69')
393 expect(slider.attributes('max')).toBe('-30')
394 })
395 
396 it('updates the chosen year when the slider moves', async () => {
397 gameStore.figureGrounding = CLEO as never
398 gameStore.contactWhen = -49
399 const wrapper = mount(FigurePicker)
400 
401 await wrapper.find('[data-testid="when-slider"]').setValue('-40')
402 expect(gameStore.contactWhen).toBe(-40)
403 })
404 
405 it('shows the figure age at the chosen year, and updates it live with the slider', async () => {
406 gameStore.figureGrounding = CLEO as never
407 gameStore.contactWhen = -49
408 const wrapper = mount(FigurePicker)
409 
410 // 49 BC: Cleopatra (born 69 BC) is 20.
411 expect(wrapper.find('[data-testid="when-age"]').text()).toContain('age 20')
412 
413 await wrapper.find('[data-testid="when-slider"]').setValue('-40')
414 expect(wrapper.find('[data-testid="when-age"]').text()).toContain('age 29')
415 })
416 
417 it('omits the age when there is no birth year to anchor it', () => {
418 gameStore.figureGrounding = { name: 'Nobody', resolved: false } as never
419 const wrapper = mount(FigurePicker)
420 expect(wrapper.find('[data-testid="when-age"]').exists()).toBe(false)
421 })
422 
423 it('pins a moment: month chips refine the display, day clamps, unpin restores (issue #32)', async () => {
424 gameStore.figureGrounding = CLEO as never
425 gameStore.contactWhen = -48
426 const wrapper = mount(FigurePicker)
427 
428 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('48 BC')
429 await wrapper.find('[data-testid="pin-moment"]').trigger('click')
430 
431 const chips = wrapper.findAll('[data-testid="month-chip"]')
432 expect(chips).toHaveLength(12)
433 await chips[2].trigger('click') // March
434 expect(gameStore.contactMoment).toEqual({ month: 3 })
435 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('March 48 BC')
436 
437 // Day input clamps to the month's cap (no calendar math, just the table).
438 await wrapper.find('[data-testid="moment-day"]').setValue('45')
439 expect(gameStore.contactMoment).toEqual({ month: 3, day: 31 })
440 await wrapper.find('[data-testid="moment-day"]').setValue('15')
441 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('March 15, 48 BC')
442 
443 // The slider keeps the pin; unpin restores the plain year.
444 await wrapper.find('[data-testid="when-slider"]').setValue('-44')
445 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('March 15, 44 BC')
446 await wrapper.find('[data-testid="unpin-moment"]').trigger('click')
447 expect(gameStore.contactMoment).toBeNull()
448 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('44 BC')
449 expect(wrapper.find('[data-testid="when-display"]').text()).not.toContain('March')
450 })
451 
452 it('blocks a resolved figure with no dates — fail closed (#72)', () => {
453 gameStore.figureGrounding = {
454 name: 'Mysterious Sage',
455 resolved: true,
456 description: 'Semi-legendary teacher'
457 } as never
458 const wrapper = mount(FigurePicker)
459 
460 // No death date → can't confirm deceased → blocked, not a permissive note.
461 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(true)
462 expect(wrapper.find('[data-testid="contact-blocked"]').exists()).toBe(true)
463 expect(wrapper.find('[data-testid="contact-blocked"]').text()).toContain("couldn't be dated")
464 expect(wrapper.find('[data-testid="when-control"]').exists()).toBe(false)
465 expect(wrapper.find('[data-testid="when-slider"]').exists()).toBe(false)
466 expect(wrapper.find('[data-testid="dossier-lifespan"]').exists()).toBe(false)
467 })
468 
469 it('blocks a living figure — deceased only (#72)', () => {
470 gameStore.figureGrounding = {
471 name: 'A Living Legend',
472 resolved: true,
473 description: 'A contemporary figure',
474 born: { year: 1950, bce: false, signed: 1950, display: '1950' },
475 living: true
476 } as never
477 const wrapper = mount(FigurePicker)
478 
479 expect(wrapper.find('[data-testid="contact-blocked"]').exists()).toBe(true)
480 expect(wrapper.find('[data-testid="contact-blocked"]').text()).toContain('still living')
481 expect(wrapper.find('[data-testid="when-control"]').exists()).toBe(false)
482 })
483 
484 it('passes circa displays through verbatim — one "c." marker, never on the chosen year', () => {
485 gameStore.figureGrounding = {
486 name: 'Homer',
487 resolved: true,
488 description: 'Ancient Greek poet',
489 born: { year: 800, bce: true, signed: -800, display: 'c. 800 BC', circa: true },
490 died: { year: 720, bce: true, signed: -720, display: 'c. 720 BC', circa: true },
491 living: false
492 } as never
493 gameStore.contactWhen = -760
494 const wrapper = mount(FigurePicker)
495 
496 // Exact equality: a component-side re-format (a second "c." prefix, a
497 // dropped marker) must fail here, not ship.
498 expect(wrapper.find('[data-testid="dossier-lifespan"]').text()).toBe('c. 800 BC – c. 720 BC')
499 const slider = wrapper.find('[data-testid="when-slider"]')
500 expect(slider.attributes('min')).toBe('-800')
501 expect(slider.attributes('max')).toBe('-720')
502 // The chosen year itself stays exact — the player picked it.
503 expect(wrapper.find('[data-testid="when-display"]').text()).not.toContain('c.')
504 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('760 BC')
505 })
506 
507 it('offers a "Study them" action for a grounded figure', () => {
508 gameStore.figureGrounding = CLEO as never
509 const wrapper = mount(FigurePicker)
510 expect(wrapper.find('[data-testid="study-button"]').exists()).toBe(true)
511 expect(wrapper.find('[data-testid="figure-study"]').exists()).toBe(false)
512 })
513 
514 it('renders the Archivist brief once the figure has been studied', () => {
515 gameStore.figureGrounding = CLEO as never
516 gameStore.figureStudy = {
517 atThisMoment: 'Queen at the height of her power.',
518 grasp: ['statecraft'],
519 reaching: ['securing Egypt against Rome'],
520 cannotYetKnow: 'the fall of the Republic'
521 }
522 gameStore.studyFor = 'Cleopatra'
523 gameStore.studyWhen = -49
524 gameStore.contactWhen = -49
525 const wrapper = mount(FigurePicker)
526 
527 expect(wrapper.find('[data-testid="figure-study"]').exists()).toBe(true)
528 expect(wrapper.text()).toContain('Queen at the height of her power')
529 expect(wrapper.text()).toContain('securing Egypt against Rome')
530 expect(wrapper.text()).toContain('the fall of the Republic')
531 // The brief replaces the prompt to study; same year, so no re-study prompt.
532 expect(wrapper.find('[data-testid="study-button"]').exists()).toBe(false)
533 expect(wrapper.find('[data-testid="restudy-button"]').exists()).toBe(false)
534 })
535 
536 it('offers a re-study once the contact year has moved since the brief', () => {
537 gameStore.figureGrounding = CLEO as never
538 gameStore.figureStudy = {
539 atThisMoment: 'Queen at the height of her power.',
540 grasp: ['statecraft'], reaching: ['securing Egypt'], cannotYetKnow: 'the fall of the Republic'
541 }
542 gameStore.studyFor = 'Cleopatra'
543 gameStore.studyWhen = -49 // studied at 49 BC
544 gameStore.contactWhen = -40 // but the slider has since moved to 40 BC
545 const wrapper = mount(FigurePicker)
546 
547 const restudy = wrapper.find('[data-testid="restudy-button"]')
548 expect(restudy.exists()).toBe(true)
549 expect(restudy.text()).toContain('40 BC')
550 })
551 
552 it('blocks an unresolved figure and guides to a real match (#73)', () => {
553 gameStore.figureGrounding = { name: 'Nobody', resolved: false } as never
554 const wrapper = mount(FigurePicker)
555 const unresolved = wrapper.find('[data-testid="figure-unresolved"]')
556 expect(unresolved.exists()).toBe(true)
557 expect(unresolved.text()).toMatch(/reach for a real/i)
558 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(false)
559 })
560 
561 it('shows era-relevant suggestions with reasons once loaded', () => {
562 // Pre-seed as already-loaded so onMounted's loadSuggestions is a cache no-op.
563 gameStore.currentObjective = { title: 'Prevent the Great War', description: '', era: '', icon: '🕊️' } as never
564 gameStore.suggestionsFor = 'Prevent the Great War'
565 gameStore.figureSuggestions = [
566 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna.', lifespan: '1859 – 1941' }
567 ]
568 
569 const wrapper = mount(FigurePicker)
570 const chips = wrapper.findAll('[data-testid="suggestion-chip"]')
571 expect(chips).toHaveLength(1)
572 expect(chips[0].text()).toContain('Wilhelm II')
573 expect(wrapper.find('[data-testid="suggestion-reason"]').text()).toContain('restrain Vienna')
574 })
575 
576 it('drops already-contacted figures from the suggestion list (issue #130)', () => {
577 // Pre-seed as already-loaded so onMounted's loadSuggestions is a cache no-op.
578 gameStore.currentObjective = { title: 'Prevent the Great War', description: '', era: '', icon: '🕊️' } as never
579 gameStore.suggestionsFor = 'Prevent the Great War'
580 gameStore.figureSuggestions = [
581 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna.', lifespan: '1859 – 1941' },
582 { name: 'Gavrilo Princip', reason: 'The shot that lit the fuse.', lifespan: '1894 – 1918' }
583 ]
584 // Wilhelm has already been messaged this run: he belongs in the contacts row,
585 // not re-proposed every turn as someone to try next.
586 gameStore.figures = [{ name: 'Wilhelm II', era: 'Imperial Germany', descriptor: 'Kaiser' }]
587 
588 const wrapper = mount(FigurePicker)
589 const names = wrapper.findAll('[data-testid="suggestion-chip"]').map(c => c.text())
590 expect(names).toHaveLength(1)
591 expect(names[0]).toContain('Gavrilo Princip')
592 expect(names.some(n => n.includes('Wilhelm II'))).toBe(false)
593 // Still reachable from the contacts row, just not the suggestions.
594 expect(wrapper.find('[data-testid="contact-chip"]').text()).toContain('Wilhelm II')
595 })
596 
597 it('notes the list is exhausted once every suggestion has been contacted (issue #130)', () => {
598 gameStore.currentObjective = { title: 'Prevent the Great War', description: '', era: '', icon: '🕊️' } as never
599 gameStore.suggestionsFor = 'Prevent the Great War'
600 gameStore.figureSuggestions = [
601 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna.' }
602 ]
603 gameStore.figures = [{ name: 'Wilhelm II', era: '', descriptor: '' }]
604 
605 const wrapper = mount(FigurePicker)
606 expect(wrapper.findAll('[data-testid="suggestion-chip"]')).toHaveLength(0)
607 // A dead header over nothing would read as broken; say so and point to typing.
608 expect(wrapper.find('[data-testid="suggestions-exhausted"]').exists()).toBe(true)
609 })
610})