fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason

Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped),
plus the admin refusal copy gap round 1 surfaced.

- A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns
  focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the
  surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link).
- Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the
  focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists.
- The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after
  new results starts at the first visible item; a menu or dialog opened from the item keeps it.
- The list keys reach the cheat sheet only while a list has an item.
- A j-focused card root is a named group while it holds the focus.
- The channel page's Block and Hide toggles keep the focus while their request runs.
- A split export that fails part-way says how many clips were added and deselects them, so a retry adds only
  the rest.
- Config save, the one-click share link and "log out other sessions" report failures with their reason.
- VirtualGrid uses the one scroll-parent walk listNav measures against.
- Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again";
  the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail.
  HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
This commit is contained in:
2026-09-13 19:18:04 +02:00
parent f130a1c05a
commit 3cd0c525e2
33 changed files with 640 additions and 113 deletions
+18
View File
@@ -149,6 +149,24 @@ describe("req — a structured refusal reads in the user's language", () => {
});
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
});
it.each([
[{ code: "activate", reason: "not_allowed" }, "errors.admin.not_allowed"],
[{ code: "invite_send", reason: "not_approved" }, "errors.admin.not_approved"],
[{ code: "resend", reason: "send_failed" }, "errors.admin.send_failed"],
[{ code: "setpw", reason: "demo" }, "errors.admin.demo"],
])("says why an admin action was refused (%o)", async (detail, localized) => {
// These read as the generic "try again" — which no retry fixes when the address is not approved.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail }), {
status: 409,
headers: { "content-type": "application/json" },
}),
);
await expect(req("/api/admin/x", { method: "POST" }, { quiet: true })).rejects.toMatchObject({
detail: localized,
});
});
});
describe("req — what it has already put in front of the user", () => {
+9
View File
@@ -62,6 +62,10 @@ export class HttpError extends Error {
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send", "setpw"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
@@ -125,6 +129,11 @@ function localizeDetail(d: unknown): string {
: t("plex.collection.editFailed");
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by, a set-password link for the shared demo account. The toast's title already names the button;
// this is the WHY, and it used to be the generic "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.