What changed, and why

The "Who in history will you reach?" autocomplete often showed nothing — even for an easy prefix like "Leonardo" — and the dropdown just never opened, with no error. It looked like "no matches" when the real cause was a transient Wikipedia outage (issue #58).

The dominant cause is Wikipedia rate-limiting. On board load three callers hit one egress IP: this autocomplete (per keystroke), the figure grounding (350ms debounce), and the era suggester (on mount). The autocomplete is the most frequent caller, so it starves first. Two things make that worse: a User-Agent with no contact draws harder throttling (Wikimedia's UA policy), and the client did a single retry then silently gave up.

The fix has two halves. On the server, one shared, policy-compliant User-Agent with a reachable contact, sent by all three callers. On the client, spaced backoff retries and — when the record truly can't be reached — a visible, retryable hint instead of an empty box. The rest of this guide walks both halves at the merged state, then the tests that pin them.

One shared, policy-compliant User-Agent

Wikimedia's User-Agent policy asks that a client identify itself and carry a reachable contact, so an operator can be reached about problematic traffic; an agent without one is throttled harder. The header used to be duplicated across two files with per-feature wording and no contact. It is now a single exported constant with a mailto: contact (the deployed site is auth-gated and the repo is private, so neither would reach a human).

The one header, exported so every caller sends the identical compliant string.

server/utils/figure-grounding.ts · 292 lines
server/utils/figure-grounding.ts292 lines · TypeScript
⋯ 52 lines hidden (lines 1–52)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53/**
54 * The one header for every Wikipedia / Wikidata call the server makes — grounding,
55 * the name autocomplete, and the suggester's lookups all share it (issue #58).
56 * Wikimedia's User-Agent policy wants the client identified plus a reachable CONTACT
57 * so an operator can be reached about problematic traffic; an agent without one gets
58 * throttled harder, and rate-limit blips are the dominant real-world failure for the
59 * autocomplete. A mailto is that contact — the deployed site is auth-gated and the
60 * repo private, so neither would reach a human. Exported so every caller sends the
61 * identical compliant string instead of drifting per-feature copies.
62 */
63export const WIKI_HEADERS = {
64 accept: 'application/json',
65 'User-Agent': 'Revisionist/1.0 (timeline RPG; mailto:matthew@mseeks.me)'
⋯ 226 lines hidden (lines 67–292)
67 
68const FULL_TTL_MS = 24 * 60 * 60 * 1000
69const TRANSIENT_TTL_MS = 10 * 60 * 1000
70const WIKIDATA_RETRY_DELAY_MS = 400
71const cache = new Map<string, { value: GroundedFigure; expires: number }>()
72 
73/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
74export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
75 const base = bce ? `${year} BC` : `${year}`
76 return {
77 year,
78 bce,
79 signed: bce ? -year : year,
80 display: circa ? `c. ${base}` : base,
81 ...(circa ? { circa: true } : {})
82 }
84 
85/**
86 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
87 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
88 * still carries a representative year in the literal, but only approximately —
89 * those parse as `circa` rather than being dropped or displayed as exact.
90 */
91export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
92 if (!time) return undefined
93 const m = /^([+-])0*(\d+)-/.exec(time)
94 if (!m) return undefined
95 const year = parseInt(m[2], 10)
96 if (!year) return undefined
97 const bce = m[1] === '-'
98 return makeFigureYear(year, bce, precision != null && precision < 9)
100 
101/** The slice of the Wikipedia REST summary we actually read. */
102interface WikiSummary {
103 type?: string
104 title?: string
105 description?: string
106 extract?: string
107 thumbnail?: { source?: string }
108 content_urls?: { desktop?: { page?: string } }
109 wikibase_item?: string
111 
112/** The slice of a Wikidata date claim we actually read. */
113interface WikidataClaim {
114 rank?: string
115 mainsnak?: {
116 snaktype?: string
117 datavalue?: { value?: { time?: string; precision?: number } }
118 }
120 
121/** The slice of the Wikidata `wbgetentities` response we actually read. */
122interface WikidataEntities {
123 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
125 
126/**
127 * The best date a claim list actually carries. Wikidata routinely stacks several
128 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
129 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
130 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
131 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
132 */
133export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
134 const usable = (claims ?? []).filter(c =>
135 c.rank !== 'deprecated' &&
136 c.mainsnak?.snaktype === 'value' &&
137 c.mainsnak.datavalue?.value?.time
138 )
139 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
140 const value = best?.mainsnak?.datavalue?.value
141 return parseWikidataYear(value?.time, value?.precision)
143 
144/**
145 * A fetch that tells a definitive miss from a transient one: 404 means the thing
146 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
147 * network error — is weather to retry soon, not remember for a day.
148 */
149interface FetchOutcome<T> {
150 data: T | null
151 transient: boolean
153 
154async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
155 try {
156 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
157 if (!res.ok) return { data: null, transient: res.status !== 404 }
158 return { data: (await res.json()) as T, transient: false }
159 } catch {
160 return { data: null, transient: true }
161 }
163 
164interface WikidataDates {
165 born?: FigureYear
166 died?: FigureYear
167 /** True when we never got a real answer (so the result must not cache long). */
168 transient: boolean
170 
171/**
172 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
173 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
174 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
175 * a no-entities response gets one spaced retry before being declared transient.
176 */
177async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
178 const subject = qid
179 ? `ids=${encodeURIComponent(qid)}`
180 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
181 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
182 let outcome = await fetchJson<WikidataEntities>(url)
183 if (!outcome.data?.entities) {
184 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
185 outcome = await fetchJson<WikidataEntities>(url)
186 }
187 const entities = outcome.data?.entities
188 if (!entities) return { transient: true }
189 const id = Object.keys(entities)[0]
190 const claims = id ? entities[id]?.claims : undefined
191 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
193 
194/**
195 * Resolves a name to grounded facts (cached). Never throws; returns
196 * `{ resolved: false }` when the figure can't be confidently identified.
197 */
198export async function groundFigure(rawName: string): Promise<GroundedFigure> {
199 const name = (rawName || '').trim()
200 if (!name) return { name: '', resolved: false }
201 
202 const key = name.toLowerCase()
203 const hit = cache.get(key)
204 if (hit && hit.expires > Date.now()) return hit.value
205 
206 const { value, ttlMs } = await resolve(name)
207 cache.set(key, { value, expires: Date.now() + ttlMs })
208 return value
210 
211/**
212 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
213 * relative values. These come from a remote Wikipedia response and end up bound to
214 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
215 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
216 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
217 * thumbnails the same way.
218 */
219export function safeUrl(url?: string): string | undefined {
220 if (!url) return undefined
221 try {
222 const { protocol } = new URL(url)
223 return protocol === 'https:' || protocol === 'http:' ? url : undefined
224 } catch {
225 return undefined
226 }
228 
229interface Resolution {
230 value: GroundedFigure
231 ttlMs: number
233 
234async function resolve(name: string): Promise<Resolution> {
235 const summary = await fetchJson<WikiSummary>(
236 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
237 )
238 
239 // No article, or an ambiguous disambiguation page → can't pin a person.
240 const article = summary.data
241 if (!article || article.type === 'disambiguation' || !article.title) {
242 return {
243 value: { name, resolved: false },
244 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
245 }
246 }
247 
248 const title: string = article.title
249 
250 const dates = await wikidataDates(article.wikibase_item, title)
251 let born = dates.born
252 let died = dates.died
253 let bridgeTransient = false
254 
255 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
256 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
257 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
258 // A recorded death is law: the estimate only fills what the record lacks, and an
259 // estimate that contradicts the record (born after the documented death) is
260 // dropped whole rather than shipped as an incoherent lifespan.
261 if (!born) {
262 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
263 bridgeTransient = bridge.transient
264 if (bridge.estimate) {
265 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
266 if (!died || estBorn.signed <= died.signed) {
267 born = estBorn
268 died = died ?? (bridge.estimate.died
269 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
270 : undefined)
271 }
272 }
273 }
274 
275 return {
276 value: {
277 name: title,
278 resolved: true,
279 description: article.description || undefined,
280 extract: article.extract || undefined,
281 born,
282 died,
283 living: !!born && !died,
284 thumbnail: safeUrl(article.thumbnail?.source),
285 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
286 },
287 // Anything short of a definitive answer keeps the short TTL: a transient
288 // Wikidata miss (even when the estimator papered over it — the next lookup
289 // should get a shot at the real dates) and an estimator outage alike.
290 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
291 }

The search module now imports that constant instead of defining its own, and passes it on the same fetch as before. Because the suggester's candidate lookups run through groundFigure (same module), all three Wikipedia/Wikidata callers are now identified to Wikimedia with the same compliant header — not just the search call.

Import the shared header (top); use it on the title-search fetch (bottom).

server/utils/figure-search.ts · 105 lines
server/utils/figure-search.ts105 lines · TypeScript
⋯ 10 lines hidden (lines 1–10)
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, WIKI_HEADERS } from './figure-grounding'
15 
16export interface FigureSearchResult {
⋯ 39 lines hidden (lines 17–55)
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
29 
30/** Cache: queries repeat heavily while typing (and across players). Capped so a
31 * crawl of unique queries can't grow it without bound (the grounding cache's
32 * known gap — not repeated here). */
33const CACHE_TTL_MS = 60 * 60 * 1000
34const CACHE_MAX_ENTRIES = 500
35const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
36 
37const RESULT_LIMIT = 5
38const SEARCH_RETRY_DELAY_MS = 250
39 
40/** The slice of the Wikipedia REST v1 title-search response we read. */
41interface WikiTitleSearch {
42 pages?: Array<{
43 title?: string
44 description?: string
45 thumbnail?: { url?: string } | null
46 }>
48 
49/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
50function absoluteThumb(url?: string): string | undefined {
51 if (!url) return undefined
52 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
54 
55/** One attempt against the title-search endpoint; null means it didn't answer. */
56async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
57 try {
58 const res = await fetch(
59 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
60 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
61 )
⋯ 44 lines hidden (lines 62–105)
62 if (!res.ok) return null
63 const data = (await res.json()) as WikiTitleSearch
64 return (data.pages ?? [])
65 .filter(p => typeof p.title === 'string' && p.title.length > 0)
66 .slice(0, RESULT_LIMIT)
67 .map(p => ({
68 name: p.title as string,
69 description: p.description || undefined,
70 thumbnail: absoluteThumb(p.thumbnail?.url)
71 }))
72 } catch {
73 return null
74 }
76 
77export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
78 const query = (rawQuery || '').trim()
79 if (query.length < 2) return { results: [], transient: false }
80 
81 const key = query.toLowerCase()
82 const hit = cache.get(key)
83 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
84 
85 let results = await fetchSearch(query)
86 if (results === null) {
87 // Rate-limit blips are the dominant real-world failure here (shared egress
88 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
89 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
90 results = await fetchSearch(query)
91 }
92 if (results === null) return { results: [], transient: true }
93 
94 // Cache REAL ANSWERS only — a timeout or a Wikipedia 5xx must not blank this
95 // query's autocomplete for every player for the next hour. (A legitimate
96 // "no such title" empty result IS an answer and caches normally.)
97 if (cache.size >= CACHE_MAX_ENTRIES) {
98 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
99 // eviction that keeps the cap honest without LRU machinery.
100 const oldest = cache.keys().next().value
101 if (oldest !== undefined) cache.delete(oldest)
102 }
103 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
104 return { results, transient: false }

Backoff, not a single silent give-up

The combobox state grows three members: searchUnavailable (the give-up flag), listboxOpen (a computed that is true only when there are results to show), and SEARCH_RETRY_DELAYS_MS, the backoff schedule. Splitting "the listbox is open" from "the popup is showing something" matters: the unavailable hint is a popup but not a listbox, so ARIA must track the listbox alone (next section).

New combobox state: the give-up flag, the listbox-open computed, the backoff schedule.

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

runSearch is where the behavior changes. A transient empty answer (rate-limit weather, flagged by the server) no longer retries once and stops. It walks the backoff schedule — 800ms, then 1600ms — re-firing for the same sequence number. Only when the schedule is exhausted and nothing is already on screen does it set searchUnavailable and force the popup open. A successful or genuinely-empty answer clears the flag and renders as before.

Backoff via SEARCH_RETRY_DELAYS_MS[attempt]; give up visibly only with nothing to show.

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

The visible, retryable hint

When the give-up branch fires, the popup shows a plain status surface with a Retry button instead of an empty listbox. The v-else-if ties it to searchOpen && searchUnavailable, and it sits as a sibling of the listbox (v-if="listboxOpen"), so exactly one of the two ever renders.

The unavailable hint: a sibling of the listbox, with a Retry button.

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

Screen-reader users get the same honesty through the existing sr-only status region (line 12). Its computed gains an outage branch that announces the failure and how to recover — deliberately not the misleading "0 matches" the old code would have read out when searchOpen was true with zero results.

The status computed: outage message first, then the match count, else silent.

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

Dismissal and focus correctness

Three small correctness points, all surfaced by an adversarial review of the diff. First, closeSearch now cancels any pending backoff timer. A retry armed before a dismissal would otherwise fire later and pop the dropdown — or the unavailable hint — back up over a field the player had walked away from.

closeSearch clears the timer (and bumps the seq) so no armed retry resurfaces.

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

Second, Escape is handled before the populated-listbox guard, so it dismisses any open surface — a listbox or the unavailable hint — and, even when nothing is on screen yet, cancels a pending retry through closeSearch. Without this, Escape during a backoff wait left the timer alive.

Escape dismisses everything and cancels pending retries, ahead of the listbox-only guard.

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

Third, retrySearch returns focus to the input before clearing the flag. Clearing searchUnavailable unmounts the div that holds the Retry button, so a keyboard user who just activated it would otherwise have focus fall to <body> and lose their place in the combobox.

Focus the input first, then tear down the hint the button lives in.

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

Tests that pin the behavior

The server test asserts the header is compliant: it carries the app name and a reachable contact (a mailto: or URL). It is a discriminating test — drop the contact and it fails.

The User-Agent carries the app name plus a reachable contact.

tests/unit/server/utils/figure-search.spec.ts · 125 lines
tests/unit/server/utils/figure-search.spec.ts125 lines · TypeScript
⋯ 68 lines hidden (lines 1–68)
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { searchFigures } from '../../../../server/utils/figure-search'
3 
4/** Wikipedia REST v1 title-search response, shaped like the real thing. */
5function wikiResponse(pages: unknown[]) {
6 return {
7 ok: true,
8 json: async () => ({ pages })
9 } as Response
11 
12describe('figure search (name autocomplete)', () => {
13 let fetchMock: ReturnType<typeof vi.fn>
14 
15 beforeEach(() => {
16 fetchMock = vi.fn()
17 vi.stubGlobal('fetch', fetchMock)
18 })
19 
20 afterEach(() => {
21 vi.useRealTimers()
22 vi.unstubAllGlobals()
23 })
24 
25 it('maps titles, descriptions, and protocol-relative thumbnails', async () => {
26 fetchMock.mockResolvedValue(wikiResponse([
27 {
28 title: 'Cleopatra',
29 description: 'Pharaoh of Egypt from 51 to 30 BC',
30 thumbnail: { url: '//upload.wikimedia.org/kleo.jpg' }
31 },
32 { title: 'Cleopatra (1963 film)', description: '1963 film', thumbnail: null }
33 ]))
34 
35 const outcome = await searchFigures('cleopat')
36 
37 expect(outcome.transient).toBe(false)
38 expect(outcome.results).toEqual([
39 {
40 name: 'Cleopatra',
41 description: 'Pharaoh of Egypt from 51 to 30 BC',
42 thumbnail: 'https://upload.wikimedia.org/kleo.jpg'
43 },
44 { name: 'Cleopatra (1963 film)', description: '1963 film', thumbnail: undefined }
45 ])
46 })
47 
48 it('drops unsafe thumbnail schemes (the unsafe-render rule)', async () => {
49 fetchMock.mockResolvedValue(wikiResponse([
50 { title: 'Spoofed', description: 'x', thumbnail: { url: 'javascript:alert(1)' } }
51 ]))
52 const { results } = await searchFigures('spoofed person')
53 expect(results[0].thumbnail).toBeUndefined()
54 })
55 
56 it('returns [] without fetching for queries under two characters', async () => {
57 expect(await searchFigures('a')).toEqual({ results: [], transient: false })
58 expect(await searchFigures(' ')).toEqual({ results: [], transient: false })
59 expect(fetchMock).not.toHaveBeenCalled()
60 })
61 
62 it('caches a query — the second call never refetches', async () => {
63 fetchMock.mockResolvedValue(wikiResponse([{ title: 'Nikola Tesla', description: 'Inventor' }]))
64 await searchFigures('tesla cache probe')
65 await searchFigures('Tesla Cache Probe') // case-insensitive key
66 expect(fetchMock).toHaveBeenCalledTimes(1)
67 })
68 
69 it('sends a Wikimedia-policy-compliant User-Agent (identifies the app + a contact URL)', async () => {
70 // A non-compliant agent (no contact) gets throttled harder — the dominant
71 // cause of the autocomplete silently failing (issue #58).
72 fetchMock.mockResolvedValue(wikiResponse([{ title: 'Leonardo da Vinci', description: 'Polymath' }]))
73 await searchFigures('leonardo ua probe')
74 
75 const init = fetchMock.mock.calls[0][1] as RequestInit
76 const ua = String((init.headers as Record<string, string>)['User-Agent'])
77 expect(ua).toContain('Revisionist')
78 expect(ua).toMatch(/mailto:\S+@\S+|https?:\/\/\S+/) // a reachable contact, per the UA policy
79 })
80 
81 it('retries a rate-limited search once and recovers the matches', async () => {
⋯ 44 lines hidden (lines 82–125)
82 vi.useFakeTimers()
83 fetchMock
84 .mockResolvedValueOnce({ ok: false, status: 429 } as Response)
85 .mockResolvedValueOnce(wikiResponse([{ title: 'Alfred Nobel', description: 'Inventor of dynamite' }]))
86 
87 const pending = searchFigures('alfred retry probe')
88 await vi.advanceTimersByTimeAsync(250)
89 const outcome = await pending
90 
91 expect(outcome.transient).toBe(false)
92 expect(outcome.results[0].name).toBe('Alfred Nobel')
93 expect(fetchMock).toHaveBeenCalledTimes(2)
94 })
95 
96 it('a failure that survives the retry is flagged transient — weather, not "no matches"', async () => {
97 vi.useFakeTimers()
98 fetchMock.mockRejectedValue(new Error('offline'))
99 
100 const pending = searchFigures('failing query one')
101 await vi.advanceTimersByTimeAsync(250)
102 expect(await pending).toEqual({ results: [], transient: true })
103 })
104 
105 it('a real empty answer is NOT transient (and caches like any answer)', async () => {
106 fetchMock.mockResolvedValue(wikiResponse([]))
107 expect(await searchFigures('xqzv nobody')).toEqual({ results: [], transient: false })
108 await searchFigures('xqzv nobody')
109 expect(fetchMock).toHaveBeenCalledTimes(1)
110 })
111 
112 it('never caches a failure — the next call for the same query refetches', async () => {
113 vi.useFakeTimers()
114 fetchMock.mockRejectedValueOnce(new Error('wikipedia blip')).mockRejectedValueOnce(new Error('still down'))
115 const pending = searchFigures('transient blip probe')
116 await vi.advanceTimersByTimeAsync(250)
117 expect((await pending).transient).toBe(true)
118 
119 fetchMock.mockResolvedValueOnce(wikiResponse([{ title: 'Recovered', description: 'after the blip' }]))
120 const second = await searchFigures('transient blip probe')
121 
122 expect(fetchMock).toHaveBeenCalledTimes(3)
123 expect(second.results).toEqual([{ name: 'Recovered', description: 'after the blip', thumbnail: undefined }])
124 })
125})

The component tests cover the new client paths end to end: the visible retryable hint after backoff exhausts (and that Retry recovers), a fresh keystroke retiring the hint, Escape cancelling a pending retry so nothing pops up later, Escape dismissing the visible hint, and keyboard Retry keeping focus in the combobox. Each maps to one of the fixes above and would fail if that fix were reverted.

The issue-#58 block: backoff give-up, retry recovery, Escape, and focus retention.

tests/unit/components/FigurePicker.spec.ts · 556 lines
tests/unit/components/FigurePicker.spec.ts556 lines · TypeScript
⋯ 202 lines hidden (lines 1–202)
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 )
⋯ 256 lines hidden (lines 301–556)
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('says so honestly when a resolved figure has no dates — note, no slider, no lifespan', () => {
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 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(true)
461 expect(wrapper.find('[data-testid="when-control"]').exists()).toBe(false)
462 expect(wrapper.find('[data-testid="when-slider"]').exists()).toBe(false)
463 expect(wrapper.find('[data-testid="dossier-lifespan"]').exists()).toBe(false)
464 expect(wrapper.find('[data-testid="when-unknown"]').text()).toContain('No dates could be found')
465 })
466 
467 it('passes circa displays through verbatim — one "c." marker, never on the chosen year', () => {
468 gameStore.figureGrounding = {
469 name: 'Homer',
470 resolved: true,
471 description: 'Ancient Greek poet',
472 born: { year: 800, bce: true, signed: -800, display: 'c. 800 BC', circa: true },
473 died: { year: 720, bce: true, signed: -720, display: 'c. 720 BC', circa: true },
474 living: false
475 } as never
476 gameStore.contactWhen = -760
477 const wrapper = mount(FigurePicker)
478 
479 // Exact equality: a component-side re-format (a second "c." prefix, a
480 // dropped marker) must fail here, not ship.
481 expect(wrapper.find('[data-testid="dossier-lifespan"]').text()).toBe('c. 800 BC – c. 720 BC')
482 const slider = wrapper.find('[data-testid="when-slider"]')
483 expect(slider.attributes('min')).toBe('-800')
484 expect(slider.attributes('max')).toBe('-720')
485 // The chosen year itself stays exact — the player picked it.
486 expect(wrapper.find('[data-testid="when-display"]').text()).not.toContain('c.')
487 expect(wrapper.find('[data-testid="when-display"]').text()).toContain('760 BC')
488 })
489 
490 it('offers a "Study them" action for a grounded figure', () => {
491 gameStore.figureGrounding = CLEO as never
492 const wrapper = mount(FigurePicker)
493 expect(wrapper.find('[data-testid="study-button"]').exists()).toBe(true)
494 expect(wrapper.find('[data-testid="figure-study"]').exists()).toBe(false)
495 })
496 
497 it('renders the Archivist brief once the figure has been studied', () => {
498 gameStore.figureGrounding = CLEO as never
499 gameStore.figureStudy = {
500 atThisMoment: 'Queen at the height of her power.',
501 grasp: ['statecraft'],
502 reaching: ['securing Egypt against Rome'],
503 cannotYetKnow: 'the fall of the Republic'
504 }
505 gameStore.studyFor = 'Cleopatra'
506 gameStore.studyWhen = -49
507 gameStore.contactWhen = -49
508 const wrapper = mount(FigurePicker)
509 
510 expect(wrapper.find('[data-testid="figure-study"]').exists()).toBe(true)
511 expect(wrapper.text()).toContain('Queen at the height of her power')
512 expect(wrapper.text()).toContain('securing Egypt against Rome')
513 expect(wrapper.text()).toContain('the fall of the Republic')
514 // The brief replaces the prompt to study; same year, so no re-study prompt.
515 expect(wrapper.find('[data-testid="study-button"]').exists()).toBe(false)
516 expect(wrapper.find('[data-testid="restudy-button"]').exists()).toBe(false)
517 })
518 
519 it('offers a re-study once the contact year has moved since the brief', () => {
520 gameStore.figureGrounding = CLEO as never
521 gameStore.figureStudy = {
522 atThisMoment: 'Queen at the height of her power.',
523 grasp: ['statecraft'], reaching: ['securing Egypt'], cannotYetKnow: 'the fall of the Republic'
524 }
525 gameStore.studyFor = 'Cleopatra'
526 gameStore.studyWhen = -49 // studied at 49 BC
527 gameStore.contactWhen = -40 // but the slider has since moved to 40 BC
528 const wrapper = mount(FigurePicker)
529 
530 const restudy = wrapper.find('[data-testid="restudy-button"]')
531 expect(restudy.exists()).toBe(true)
532 expect(restudy.text()).toContain('40 BC')
533 })
534 
535 it('falls back gracefully for an unresolved figure', () => {
536 gameStore.figureGrounding = { name: 'Nobody', resolved: false } as never
537 const wrapper = mount(FigurePicker)
538 expect(wrapper.find('[data-testid="figure-unresolved"]').exists()).toBe(true)
539 expect(wrapper.find('[data-testid="figure-dossier"]').exists()).toBe(false)
540 })
541 
542 it('shows era-relevant suggestions with reasons once loaded', () => {
543 // Pre-seed as already-loaded so onMounted's loadSuggestions is a cache no-op.
544 gameStore.currentObjective = { title: 'Prevent the Great War', description: '', era: '', icon: '🕊️' } as never
545 gameStore.suggestionsFor = 'Prevent the Great War'
546 gameStore.figureSuggestions = [
547 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna.', lifespan: '1859 – 1941' }
548 ]
549 
550 const wrapper = mount(FigurePicker)
551 const chips = wrapper.findAll('[data-testid="suggestion-chip"]')
552 expect(chips).toHaveLength(1)
553 expect(chips[0].text()).toContain('Wilhelm II')
554 expect(wrapper.find('[data-testid="suggestion-reason"]').text()).toContain('restrain Vienna')
555 })
556})