## When things go wrong Thirty rounds built the happy path. A phone is also in a lift, signed out, on a dead cell, or holding a library folder that has gone away, and until this round every one of those showed up as an empty box, a spinner with no end, or a Rust string in a red toast. **One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`, `login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer rejected: {0}`, `timed out`), the commands' own literals (`not connected to Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server: {e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a sentence a person can act on. The rule that matters: **the stores keep the raw string and only the DRAWING is translated.** `player.error` still holds what Rust said, Diagnostics and the walk still read it, and `say()` is called at the one place the toast is written — which is why fifty-nine `String(e)` sinks were fixed without touching fifty-nine call sites, and why the sixtieth is covered too. A message the backend already wrote in English ("The playlist needs a name") matches no rule and passes through untouched. The worst one it retires was on the sign-in card: a phone with no DNS drew, in red under the password box, `could not reach server: io: failed to lookup address information: no address associated with hostname`. It now says "Couldn't reach the Soulseek server. Check the connection and try again." The red toast gained the notice's shape — the sentence, and one action beside it. **Try again** when retrying is the answer (a resolve that found no sources, a library read that failed at launch), **Sign in** when `signedOut()` says the trouble is that nobody is. The action is tied to the exact message it belongs to (`player.retry = { for, fn }`), so a later, different error can never inherit the last one's button. What each screen says now, where it used to say nothing or worse: - **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable to open database file" and then "No tracks match.", which is a failed read dressed up as an empty library. Now: "Couldn't read the library index", the translated reason, and a Try again. "No tracks match" only when the read SUCCEEDED. - **A playlist** — a rejected `playlist_items` said "This playlist is empty." Empty now means empty; failed says so, with a Try again. - **A catalog record** and **an artist page** — the catalog's own error is translated, the record's stub offers a Try again, and the artist's stub says "the catalog didn't answer" rather than "had nothing to add" when that is what happened. - **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back to "See what's missing" and could be tapped for ever. It now says "Couldn't reach the catalogue — tap to try again". - **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline with follows, Home drew empty rails and said nothing. One line under the lead now does, and points at the library below. - **Search** — the whole network section sat inside `{#if online}` with no `{:else}`, so signed out with a query typed there was no row and no reason, just a gap. Now there is a line and a door to Community. - **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for this track.", and that branch was also the only one of the four with no `⋯` button, so Paste lyrics was unreachable exactly when you wanted it. Both are fixed: a failure says so, and both branches carry the `⋯`. - **Transfers** — `failed · ` on a row is `failed · `. **Two spinners were given an end.** `request_room_list` is fire-and-forget over the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and there is no reply channel and no timeout), so "Asking the server for its rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in `refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that never answers gets "The server hasn't sent its room list." and a Try again. Tracks' own "Reading the index…" has its failure branch above. Everything else was measured and left alone because it is already bounded: `user_info` 30 s, `user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s. **One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs) ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose path does not `exists()`, then every album left with no tracks. With the root unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of trouble — You → **Rescan library** emptied the whole index, silently, and returned `Ok(())`. There is now a guard above the prune: make the root if it is merely not there yet (a fresh install still works), and refuse with `library-folder-missing: ` if it cannot be made or read. You catches that into a row under Library folder with the path and what to do about it, and the toast says it in English.