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.
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
⋯ 308 lines hidden (lines 1–308)
⋯ 188 lines hidden (lines 330–517)
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
⋯ 355 lines hidden (lines 1–355)
⋯ 132 lines hidden (lines 386–517)
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
⋯ 30 lines hidden (lines 1–30)
⋯ 475 lines hidden (lines 43–517)
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
⋯ 457 lines hidden (lines 1–457)
⋯ 52 lines hidden (lines 466–517)
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
⋯ 330 lines hidden (lines 1–330)
⋯ 168 lines hidden (lines 350–517)
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
⋯ 412 lines hidden (lines 1–412)
⋯ 91 lines hidden (lines 427–517)
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
⋯ 385 lines hidden (lines 1–385)
⋯ 122 lines hidden (lines 396–517)
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
⋯ 68 lines hidden (lines 1–68)
⋯ 44 lines hidden (lines 82–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.