What changed, and why

Typing some names ("Jesus", "John F Kennedy") sometimes produced no autocomplete at all, and "Alfred Nobel" matched only near the end of the name. Probed live, the Wikipedia title-search endpoint answers every one of those queries — including "John F Kennedy" without the period. The real failure sat in our seam: a Wikipedia 429 or timeout made the search return [], byte-for- byte identical to a genuine "no matches". Nothing retried at any layer, so one blip meant the dropdown silently never opened. The cluster's shared egress IP sees such blips in bursts, and the in-app suggester's own wiki traffic competes from the same pod.

This is the same failure class PR #34 fixed in grounding, applied to search: a transient miss must stay distinguishable from a definitive answer. The change is small — the search outcome gains a transient flag, the server retries once, and the combobox quietly retries once more instead of treating weather as an answer. Worst case is exactly the old behavior; the typical blip now recovers within a second without the player doing anything.

The search outcome learns to tell weather from absence

searchFigures now returns { results, transient } instead of a bare array. One fetch attempt is factored out (fetchSearch, null when the endpoint didn't answer); a null first attempt gets one spaced retry, 250ms later. Only a real answer — including a legitimate empty one — is ever cached; a failure that survives the retry comes back transient: true and uncached, so the next keystroke gets a fresh shot.

The outcome type, the single-attempt helper, and the retry + cache-real-answers-only flow.

server/utils/figure-search.ts · 110 lines
server/utils/figure-search.ts110 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Deliberately unfiltered: the player may reach for anyone or anything
5 * Wikipedia knows; the description line is what lets them tell the pharaoh from
6 * the 1963 film. Never throws.
7 *
8 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
9 * or timeout is weather, not "no matches". A failed fetch gets one spaced retry,
10 * and if that fails too the outcome says `transient: true` instead of posing as
11 * an empty result — so the combobox can quietly try again rather than silently
12 * never opening. Only real answers are cached.
13 */
14import { safeUrl } from './figure-grounding'
15 
16export interface FigureSearchResult {
17 name: string
18 description?: string
19 thumbnail?: string
21 
22export interface FigureSearchOutcome {
23 results: FigureSearchResult[]
24 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
25 * answer is weather, and the caller may retry soon. Never set on a real
26 * "no matches" answer. */
27 transient: boolean
⋯ 31 lines hidden (lines 29–59)
29 
30const WIKI_HEADERS = {
31 accept: 'application/json',
32 'User-Agent': 'Revisionist/1.0 (timeline RPG; name autocomplete)'
34 
35/** Cache: queries repeat heavily while typing (and across players). Capped so a
36 * crawl of unique queries can't grow it without bound (the grounding cache's
37 * known gap — not repeated here). */
38const CACHE_TTL_MS = 60 * 60 * 1000
39const CACHE_MAX_ENTRIES = 500
40const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
41 
42const RESULT_LIMIT = 5
43const SEARCH_RETRY_DELAY_MS = 250
44 
45/** The slice of the Wikipedia REST v1 title-search response we read. */
46interface WikiTitleSearch {
47 pages?: Array<{
48 title?: string
49 description?: string
50 thumbnail?: { url?: string } | null
51 }>
53 
54/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
55function absoluteThumb(url?: string): string | undefined {
56 if (!url) return undefined
57 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
59 
60/** One attempt against the title-search endpoint; null means it didn't answer. */
61async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
62 try {
63 const res = await fetch(
64 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
65 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
66 )
67 if (!res.ok) return null
68 const data = (await res.json()) as WikiTitleSearch
69 return (data.pages ?? [])
70 .filter(p => typeof p.title === 'string' && p.title.length > 0)
71 .slice(0, RESULT_LIMIT)
72 .map(p => ({
73 name: p.title as string,
74 description: p.description || undefined,
75 thumbnail: absoluteThumb(p.thumbnail?.url)
76 }))
77 } catch {
78 return null
79 }
⋯ 1 line hidden (lines 81–81)
81 
82export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
83 const query = (rawQuery || '').trim()
84 if (query.length < 2) return { results: [], transient: false }
85 
86 const key = query.toLowerCase()
87 const hit = cache.get(key)
88 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
89 
90 let results = await fetchSearch(query)
91 if (results === null) {
92 // Rate-limit blips are the dominant real-world failure here (shared egress
93 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
94 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
95 results = await fetchSearch(query)
96 }
97 if (results === null) return { results: [], transient: true }
98 
99 // Cache REAL ANSWERS only — a timeout or a Wikipedia 5xx must not blank this
100 // query's autocomplete for every player for the next hour. (A legitimate
101 // "no such title" empty result IS an answer and caches normally.)
102 if (cache.size >= CACHE_MAX_ENTRIES) {
103 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
104 // eviction that keeps the cap honest without LRU machinery.
105 const oldest = cache.keys().next().value
106 if (oldest !== undefined) cache.delete(oldest)
107 }
108 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
109 return { results, transient: false }
⋯ 1 line hidden (lines 110–110)

The combobox treats a transient empty as weather

The route passes the flag through untouched. In the picker, runSearch now refuses to let a transient empty answer mean anything: it keeps whatever results are already on screen (a miss mid-type no longer blanks the list) and schedules one quiet retry 800ms later. The retry reuses the existing safety rails — it lands on the same seq guard that drops stale responses, and a new keystroke clears the timer because it lives in the same searchDebounce slot.

One added branch: transient + empty → keep what's showing, retry once.

components/FigurePicker.vue · 387 lines
components/FigurePicker.vue387 lines · Vue
⋯ 254 lines hidden (lines 1–254)
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" 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="searchOpen ? 'true' : 'false'"
9 :aria-controls="searchOpen ? '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, the description line telling
14 the pharaoh from the 1963 film. Picking one fills the input; grounding
15 fires exactly as if it had been typed. -->
16 <ul v-if="searchOpen" id="figure-search-listbox" data-testid="figure-search-listbox" role="listbox"
17 aria-label="Matching names from the record"
18 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">
19 <li v-for="(r, i) in searchResults" :id="`figure-search-option-${i}`" :key="r.name" role="option"
20 :aria-selected="i === activeIndex ? 'true' : 'false'" data-testid="figure-search-option"
21 class="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm"
22 :class="i === activeIndex ? 'rv-tint' : 'rv-hover'"
23 @mousedown.prevent @click="choose(r.name)" @mousemove="activeIndex = i">
24 <img v-if="r.thumbnail" :src="r.thumbnail" alt="" class="h-7 w-7 shrink-0 rounded object-cover" aria-hidden="true" />
25 <span v-else class="h-7 w-7 shrink-0 rounded rv-tint" aria-hidden="true" />
26 <span class="min-w-0">
27 <span class="rv-fg font-medium block truncate">{{ r.name }}</span>
28 <span v-if="r.description" class="rv-faint text-[11px] block truncate">{{ r.description }}</span>
29 </span>
30 </li>
31 </ul>
32 </div>
33 
34 <!-- Grounding: who they were, and when you reach them -->
35 <div v-if="groundingLoading" data-testid="grounding-loading" class="flex items-center gap-2 text-xs rv-faint">
36 <span class="rv-spinner" aria-hidden="true" />
37 Consulting the records…
38 </div>
39 
40 <div v-else-if="grounding?.resolved" data-testid="figure-dossier" class="rv-card p-3">
41 <div class="flex gap-3">
42 <img v-if="grounding.thumbnail" :src="grounding.thumbnail" :alt="grounding.name"
43 class="h-12 w-12 shrink-0 rounded object-cover" />
44 <div class="min-w-0 flex-1">
45 <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
46 <span data-testid="dossier-name" class="rv-fg font-semibold text-sm">{{ grounding.name }}</span>
47 <span v-if="lifespan" data-testid="dossier-lifespan" class="rv-mono text-[11px] rv-faint">{{ lifespan }}</span>
48 <a v-if="grounding.wikiUrl" :href="grounding.wikiUrl" target="_blank" rel="noopener noreferrer"
49 class="rv-accent text-[11px]">Wikipedia ↗</a>
50 </div>
51 <p v-if="grounding.description" class="rv-muted text-xs mt-0.5">{{ grounding.description }}</p>
52 
53 <!-- When do you reach them? A promoted, lifetime-bounded playhead: the
54 chosen year + live age lead, big and in the accent, so the moment you're
55 writing into is the loudest thing in the dossier (not a buried micro-row). -->
56 <div v-if="hasLifespan" data-testid="when-control" class="mt-2.5 border-t rv-line pt-2.5">
57 <div class="flex items-baseline justify-between gap-2">
58 <span id="when-label" class="rv-label">Reach them in</span>
59 <span data-testid="when-display" class="rv-mono rv-accent text-lg font-bold leading-none">
60 {{ whenDisplay }}<span v-if="contactAge != null" data-testid="when-age" class="rv-faint text-xs font-normal"> · age {{ contactAge }}</span>
61 </span>
62 </div>
63 <input type="range" data-testid="when-slider" class="w-full mt-2" :style="{ accentColor: 'var(--rv-accent)' }"
64 :min="range.min" :max="range.max" :value="contactWhen ?? range.min" :disabled="disabled"
65 aria-labelledby="when-label" :aria-valuetext="whenValueText" @input="onWhenInput" />
66 <div class="flex items-center justify-between text-[10px] rv-faint rv-mono">
67 <span>{{ grounding.born?.display }} · born</span>
68 <span>{{ grounding.died?.display ? grounding.died.display + ' · died' : 'present' }}</span>
69 </div>
70 </div>
71 
72 <!-- Even the AI bridge couldn't date them: say so honestly instead of a
73 mysteriously absent control. The copy claims only the lookup outcome
74 (it may be a transient outage, not a dateless record). No manual year
75 here — the contact year prices the anachronism wager, so it stays
76 grounded or unset. -->
77 <p v-else data-testid="when-unknown" class="mt-2.5 border-t rv-line pt-2.5 text-[11px] italic rv-faint">
78 No dates could be found for them — your message will find them in their own time.
79 </p>
80 
81 <!-- The Archive: study who you're reaching, at the chosen moment -->
82 <div data-testid="archive" class="mt-2 border-t rv-line pt-2">
83 <button v-if="!studyShown" type="button" data-testid="study-button"
84 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
85 :disabled="studyLoading || disabled" @click="studyThem">
86 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'The Archivist consults the record…' : studyLabel }}
87 </button>
88 
89 <div v-else data-testid="figure-study" class="space-y-1 text-[11px] leading-snug rv-muted">
90 <p class="italic rv-fg">{{ figureStudy?.atThisMoment }}</p>
91 <p v-if="figureStudy?.grasp.length"><span class="rv-faint">grasps</span> {{ figureStudy?.grasp.join(' · ') }}</p>
92 <p v-if="figureStudy?.reaching.length"><span class="rv-faint">reaching</span> {{ figureStudy?.reaching.join(' · ') }}</p>
93 <p v-if="figureStudy?.cannotYetKnow" class="rv-warn"><span class="rv-faint">beyond them</span> {{ figureStudy?.cannotYetKnow }}</p>
94 <button v-if="yearMoved" type="button" data-testid="restudy-button"
95 class="rv-accent font-medium hover:underline disabled:opacity-50" :disabled="studyLoading || disabled" @click="studyThem">
96 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'Consulting…' : restudyLabel }}
97 </button>
98 </div>
99 </div>
100 </div>
101 </div>
102 </div>
103 
104 <div v-else-if="grounding && !grounding.resolved" data-testid="figure-unresolved" class="text-xs italic rv-faint">
105 No record found for "{{ activeName }}" — you can still send a message into the unknown.
106 </div>
107 
108 <!-- Figures already contacted this run -->
109 <div v-if="contacted.length" class="flex flex-wrap items-center gap-1.5">
110 <span class="rv-label">contacts</span>
111 <button v-for="f in contacted" :key="f.name" type="button" data-testid="contact-chip"
112 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)">
113 <span class="rv-dot" :class="f.name === figure ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />{{ f.name }}
114 </button>
115 </div>
116 
117 <!-- Era-relevant figures for this objective (the on-ramp); you can still type anyone. -->
118 <div class="space-y-1.5">
119 <span class="rv-label block" aria-live="polite">{{ suggestionsLabel }}</span>
120 <!-- While the era-aware agent works: quiet skeleton chips — never clickable
121 generic defaults masquerading as objective-relevant picks. The famous-
122 names fallback appears only AFTER the agent has genuinely come up empty,
123 labeled honestly as what it is. -->
124 <div v-if="suggestionsPending" data-testid="suggestion-skeletons" class="flex flex-col gap-1" aria-hidden="true">
125 <div v-for="i in 3" :key="i" class="border rv-line rounded-sm px-2.5 py-2">
126 <span class="skeleton-line w-32" />
127 <span class="skeleton-line w-48 mt-1.5" />
128 </div>
129 </div>
130 <div v-else class="flex flex-col gap-1">
131 <button v-for="s in displaySuggestions" :key="s.name" type="button" data-testid="suggestion-chip"
132 class="rv-press border rv-line rounded-sm px-2.5 py-1.5 text-left" :class="suggestionClass(s.name)" @click="select(s.name)">
133 <span class="text-sm rv-fg font-medium">{{ s.name }}</span>
134 <span v-if="s.lifespan" class="ml-1.5 rv-mono text-[10px] rv-faint">{{ s.lifespan }}</span>
135 <span v-if="s.reason" data-testid="suggestion-reason" class="block text-[11px] leading-snug rv-faint">{{ s.reason }}</span>
136 </button>
137 </div>
138 </div>
139 </div>
140</template>
141 
142<script setup lang="ts">
143/**
144 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
145 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
146 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
147 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
148 */
149import { computed, watch, onMounted, onUnmounted } from 'vue'
150import { useGameStore, formatContactYear } from '~/stores/game'
151import type { FigureSuggestion } from '~/server/utils/figure-suggester'
152 
153const figure = defineModel<string>({ default: '' })
154defineProps<{ disabled?: boolean }>()
155 
156const gameStore = useGameStore()
157const contacted = computed(() => gameStore.figures)
158const figureSuggestions = computed(() => gameStore.figureSuggestions)
159const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
160const grounding = computed(() => gameStore.figureGrounding)
161const groundingLoading = computed(() => gameStore.groundingLoading)
162const contactWhen = computed(() => gameStore.contactWhen)
163const contactAge = computed(() => gameStore.contactAge)
164const activeName = computed(() => figure.value.trim())
165 
166const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
167const lifespan = computed(() => {
168 const g = grounding.value
169 if (!g?.born) return ''
170 if (g.died) return `${g.born.display}${g.died.display}`
171 return g.living ? `${g.born.display} – present` : g.born.display
172})
173const range = computed(() => ({
174 min: grounding.value?.born?.signed ?? 0,
175 max: grounding.value?.died?.signed ?? new Date().getFullYear()
176}))
177const whenDisplay = computed(() => (contactWhen.value != null ? formatContactYear(contactWhen.value) : ''))
178// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
179// not the raw signed integer the value attribute carries.
180const whenValueText = computed(() =>
181 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
183 
184function onWhenInput(e: Event) {
185 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
187 
188// The Archive: study the grounded figure in-game, at the chosen moment.
189const figureStudy = computed(() => gameStore.figureStudy)
190const studyLoading = computed(() => gameStore.studyLoading)
191const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
192const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
193const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
194const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
195function studyThem() { gameStore.studyActiveFigure() }
196 
197const LOCAL_DEFAULT: FigureSuggestion[] = [
198 { name: 'Cleopatra', reason: '' },
199 { name: 'Nikola Tesla', reason: '' },
200 { name: 'Genghis Khan', reason: '' },
201 { name: 'Marie Curie', reason: '' },
202 { name: 'Leonardo da Vinci', reason: '' }
204const displaySuggestions = computed(() => (figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT))
205// Pending = in flight, OR simply not yet asked for this objective — the latter
206// covers the first painted frame (loadSuggestions fires onMounted, after render),
207// which would otherwise flash the clickable famous-names fallback for one frame.
208// The store records suggestionsFor even on failure, so a genuine miss still
209// settles into the honest fallback rather than skeletons forever.
210const suggestionsPending = computed(() =>
211 !figureSuggestions.value.length && (
212 suggestionsLoading.value ||
213 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
214 )
216const suggestionsLabel = computed(() =>
217 suggestionsPending.value
218 ? 'finding figures who matter here…'
219 : figureSuggestions.value.length
220 ? 'figures who might matter here'
221 : 'some famous names to start with'
223 
224function select(name: string) {
225 choose(name)
227 
228// --- Name autocomplete (a combobox over Wikipedia title search) ---
229interface SearchResult { name: string; description?: string; thumbnail?: string }
230const searchBoxRef = ref<HTMLElement | null>(null)
231const searchResults = ref<SearchResult[]>([])
232const searchOpen = ref(false)
233const activeIndex = ref(-1)
234let searchDebounce: ReturnType<typeof setTimeout> | null = null
235let searchSeq = 0
236// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
237// the model change it causes must not reopen the dropdown over the fresh dossier.
238let lastChosen = ''
239 
240/** Closing also INVALIDATES any response still in flight: bumping the seq is what
241 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
242 * dossier, an emptied input, or a disabled one mid-send. */
243function closeSearch() {
244 searchSeq++
245 searchResults.value = []
246 searchOpen.value = false
247 activeIndex.value = -1
249 
250/** Deliberate selection from any surface: fill the input and stand down. */
251function choose(name: string) {
252 lastChosen = name
253 figure.value = name
254 closeSearch()
256 
257/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
258async function runSearch(q: string, seq: number, attempt = 0) {
259 try {
260 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[]; transient?: boolean }
261 if (seq !== searchSeq) return // closed, or a newer keystroke took over
262 const results = res?.results ?? []
263 // Weather, not absence: an empty answer the server marks transient must not
264 // read as "no matches" — keep whatever is showing and quietly try once more.
265 // (A new keystroke clears the timer; the seq guard drops a stale landing.)
266 if (!results.length && res?.transient) {
267 if (attempt === 0) searchDebounce = setTimeout(() => { void runSearch(q, seq, 1) }, 800)
268 return
269 }
270 searchResults.value = results
271 searchOpen.value = results.length > 0
272 activeIndex.value = -1
273 } catch {
274 if (seq === searchSeq) closeSearch()
275 }
277 
⋯ 110 lines hidden (lines 278–387)
278watch(figure, (name) => {
279 if (searchDebounce) clearTimeout(searchDebounce)
280 const q = (name || '').trim()
281 if (q.length < 2 || q === lastChosen) {
282 closeSearch()
283 // One-shot suppression: it exists to swallow the single model echo a
284 // choice causes. A later deliberate retype of the same name searches again.
285 if (q === lastChosen) lastChosen = ''
286 return
287 }
288 lastChosen = ''
289 const seq = ++searchSeq
290 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
291})
292 
293function onSearchKeydown(e: KeyboardEvent) {
294 if (e.isComposing) return // IME candidate navigation owns these keys
295 if (!searchOpen.value || !searchResults.value.length) {
296 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
297 // (the only recovery after Escape that doesn't require editing the text).
298 if (e.key === 'ArrowDown') {
299 const q = (figure.value || '').trim()
300 if (q.length >= 2) {
301 e.preventDefault()
302 void runSearch(q, ++searchSeq)
303 }
304 }
305 return
306 }
307 if (e.key === 'ArrowDown') {
308 e.preventDefault()
309 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
310 } else if (e.key === 'ArrowUp') {
311 e.preventDefault()
312 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
313 } else if (e.key === 'Enter') {
314 if (activeIndex.value >= 0) {
315 e.preventDefault()
316 choose(searchResults.value[activeIndex.value].name)
317 }
318 } else if (e.key === 'Escape') {
319 closeSearch()
320 }
322 
323/** Close when focus genuinely leaves the combobox (not when it moves within it). */
324function onSearchFocusOut(e: FocusEvent) {
325 const next = e.relatedTarget as Node | null
326 if (next && searchBoxRef.value?.contains(next)) return
327 closeSearch()
329 
330/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
331const searchStatus = computed(() =>
332 searchOpen.value
333 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
334 : ''
336 
337function chipClass(name: string) {
338 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
340function suggestionClass(name: string) {
341 return name === figure.value ? 'rv-tint' : 'rv-hover'
343 
344// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
345// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
346// fired during the debounce/fetch window must go out clean (free-form), never
347// wearing the old figure's facts, year, or liveness gate.
348let debounce: ReturnType<typeof setTimeout> | null = null
349watch(figure, (name) => {
350 if (debounce) clearTimeout(debounce)
351 gameStore.clearGrounding()
352 const trimmed = (name || '').trim()
353 if (!trimmed) return
354 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
355})
356onUnmounted(() => {
357 if (debounce) clearTimeout(debounce)
358 if (searchDebounce) clearTimeout(searchDebounce)
359})
360 
361// Load era-relevant suggestions for the current objective when the board appears.
362onMounted(() => gameStore.loadSuggestions())
363</script>
364 
365<style scoped>
366/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
367 pulse, stilled under reduced motion. Never a clickable fake. */
368.skeleton-line {
369 display: block;
370 height: 10px;
371 border-radius: 2px;
372 background: var(--rv-tint);
373 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
375@keyframes skeleton-pulse {
376 0%, 100% { opacity: .55; }
377 50% { opacity: 1; }
379@media (prefers-reduced-motion: reduce) {
380 .skeleton-line { animation: none; }
382 
383/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
384.search-pop {
385 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
387</style>

Verification

npx vitest run green (350 tests), npx nuxt typecheck clean, 12/12 Playwright e2e. The new server tests pin: a 429 recovered by the retry (figure-search.spec.ts:69), a surviving failure flagged transient and never cached (:84, :100), and a real empty answer caching as non-transient (:93). The new component tests pin the quiet retry landing matches without a keystroke and the never-blank-on-weather behavior — both routed around the grounding watcher that shares the $fetch stub.