feat(ui): distinct error feedback when @mention search fails #634

Open
opened 2026-05-19 23:32:04 +02:00 by marcel · 0 comments
Owner

Context

PR #629's runSearch in PersonMentionEditor.svelte catches non-OK responses and swallowed exceptions by setting dropdownState.items = []. The user sees the same "Keine Personen gefunden" copy as for a genuine no-results state — no signal that the system is unhealthy.

Sara (#10935, #10970, #11080) and Elicit (#10926, #11058) flagged this.

Decision

Distinct empty-state copy with retry button for 5xx / network failures, dedicated session-expired copy for 401, and the existing empty-state copy for other 4xx. Picked over the alternatives because:

  • It keeps the user in flow — no second focusable surface (toast / inline alert) competes for the keyboard.
  • It is discoverable — the user is already looking at the dropdown when the failure happens.
  • It pairs cleanly with the persistent aria-live region added in PR #629 (Leonie #3 / #11068) — the new copy announces automatically.

Rejected:

  • Toast — out of the user's foveal field; senior transcribers reported missing toast banners in #393 a11y review.
  • Inline alert above listbox — adds a second focusable target inside the dropdown, complicates ArrowDown / Tab routing (already covered by #636's aria-activedescendant scope).

Acceptance (Given-When-Then)

  1. Given the dropdown is open with a typed query, when /api/persons responds with 4xx (excluding 401), then the dropdown shows the existing empty-state copy (m.person_mention_popup_empty() — "Keine Personen gefunden") — treated as no-match.

  2. Given the dropdown is open with a typed query, when /api/persons responds with 401, then the dropdown shows m.person_mention_error_session_expired() ("Sitzung abgelaufen — bitte neu anmelden") with a link to /login inside the dropdown body. The link uses target="_blank" rel="noopener noreferrer" consistent with the create-new escape hatch.

  3. Given the dropdown is open with a typed query, when /api/persons responds with 5xx OR the fetch promise rejects (network failure), then the dropdown shows m.person_mention_error_retry() ("Suche fehlgeschlagen — erneut versuchen") with a button that re-fires the last runSearch(query). The button has min-h-[44px] and respects WCAG 2.5.5 touch-target size.

  4. Given the user clicks the retry button, when the re-fired request succeeds, then the dropdown transitions to the populated state and the persistent live region announces the new result count (Leonie #3 from PR #629).

  5. Given the user clicks the retry button, when the re-fired request still fails, then the same retry UI re-renders (idempotent — no banner stacking, no double-fetch).

i18n strings

Key de en es
person_mention_error_session_expired Sitzung abgelaufen — bitte neu anmelden Session expired — please sign in again Sesión caducada — vuelve a iniciar sesión
person_mention_error_retry Suche fehlgeschlagen — erneut versuchen Search failed — try again Búsqueda fallida — reintentar
person_mention_error_retry_btn Erneut versuchen Try again Reintentar
person_mention_error_session_link Anmelden Sign in Iniciar sesión

Test plan

  • Extend the existing characterization tests in PersonMentionEditor.svelte.spec.ts ("server failure" and "fetch reject"): both currently pin the empty-state copy. Update each to assert the new error UX per AC-3.
  • New test for AC-2: mock /api/persons returning 401; assert the session-expired copy + /login link rendered.
  • New test for AC-4: mock first fetch 500, second OK; assert retry-button-click renders the AUGUSTE option.
  • New test for AC-5: mock both fetches 500; assert clicking retry still shows the retry UI, not stacked banners.

Out of scope

  • 4xx other than 401 — treated as no-match (status quo).
  • Server-side code = SESSION_EXPIRED mapping. If the backend returns 401 but a JSON body with a generic code, treat HTTP 401 as the signal.
  • Backend rate-limit (429) UX — file as sibling if/when #633's rate-limiting lands.

Reviewer rationale: Sara #10935 / #10970 / #11080 (round 3 — commit to a design + Given-When-Then), Elicit #10926 / #11058, Tobi #11054 (401 callout).

## Context PR #629's `runSearch` in `PersonMentionEditor.svelte` catches non-OK responses and swallowed exceptions by setting `dropdownState.items = []`. The user sees the same "Keine Personen gefunden" copy as for a genuine no-results state — no signal that the system is unhealthy. Sara (#10935, #10970, #11080) and Elicit (#10926, #11058) flagged this. ## Decision **Distinct empty-state copy with retry button** for 5xx / network failures, **dedicated session-expired copy** for 401, and the existing empty-state copy for other 4xx. Picked over the alternatives because: - It keeps the user in flow — no second focusable surface (toast / inline alert) competes for the keyboard. - It is discoverable — the user is already looking at the dropdown when the failure happens. - It pairs cleanly with the persistent aria-live region added in PR #629 (Leonie #3 / #11068) — the new copy announces automatically. **Rejected**: - *Toast* — out of the user's foveal field; senior transcribers reported missing toast banners in #393 a11y review. - *Inline alert above listbox* — adds a second focusable target inside the dropdown, complicates ArrowDown / Tab routing (already covered by #636's aria-activedescendant scope). ## Acceptance (Given-When-Then) 1. **Given** the dropdown is open with a typed query, **when** `/api/persons` responds with `4xx` (excluding `401`), **then** the dropdown shows the existing empty-state copy (`m.person_mention_popup_empty()` — "Keine Personen gefunden") — treated as no-match. 2. **Given** the dropdown is open with a typed query, **when** `/api/persons` responds with `401`, **then** the dropdown shows **`m.person_mention_error_session_expired()`** ("Sitzung abgelaufen — bitte neu anmelden") with a link to `/login` inside the dropdown body. The link uses `target="_blank" rel="noopener noreferrer"` consistent with the create-new escape hatch. 3. **Given** the dropdown is open with a typed query, **when** `/api/persons` responds with `5xx` OR the fetch promise rejects (network failure), **then** the dropdown shows **`m.person_mention_error_retry()`** ("Suche fehlgeschlagen — erneut versuchen") with a button that re-fires the last `runSearch(query)`. The button has `min-h-[44px]` and respects WCAG 2.5.5 touch-target size. 4. **Given** the user clicks the retry button, **when** the re-fired request succeeds, **then** the dropdown transitions to the populated state and the persistent live region announces the new result count (Leonie #3 from PR #629). 5. **Given** the user clicks the retry button, **when** the re-fired request still fails, **then** the same retry UI re-renders (idempotent — no banner stacking, no double-fetch). ## i18n strings | Key | de | en | es | |---|---|---|---| | `person_mention_error_session_expired` | Sitzung abgelaufen — bitte neu anmelden | Session expired — please sign in again | Sesión caducada — vuelve a iniciar sesión | | `person_mention_error_retry` | Suche fehlgeschlagen — erneut versuchen | Search failed — try again | Búsqueda fallida — reintentar | | `person_mention_error_retry_btn` | Erneut versuchen | Try again | Reintentar | | `person_mention_error_session_link` | Anmelden | Sign in | Iniciar sesión | ## Test plan - Extend the existing characterization tests in `PersonMentionEditor.svelte.spec.ts` ("server failure" and "fetch reject"): both currently pin the empty-state copy. Update each to assert the new error UX per AC-3. - New test for AC-2: mock `/api/persons` returning 401; assert the session-expired copy + `/login` link rendered. - New test for AC-4: mock first fetch 500, second OK; assert retry-button-click renders the AUGUSTE option. - New test for AC-5: mock both fetches 500; assert clicking retry still shows the retry UI, not stacked banners. ## Out of scope - 4xx other than 401 — treated as no-match (status quo). - Server-side `code = SESSION_EXPIRED` mapping. If the backend returns `401` but a JSON body with a generic `code`, treat HTTP 401 as the signal. - Backend rate-limit (429) UX — file as sibling if/when #633's rate-limiting lands. Reviewer rationale: Sara #10935 / #10970 / #11080 (round 3 — commit to a design + Given-When-Then), Elicit #10926 / #11058, Tobi #11054 (401 callout).
marcel added the P3-laterfeatureui labels 2026-05-19 23:32:08 +02:00
Sign in to join this conversation.
No Label P3-later feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#634