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
@@ -53,6 +53,20 @@ const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");
const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
// ruff wraps over several lines still matches.
const adminCodes = [
...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const adminRefusals = [
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
].filter((m) => adminCodes.includes(m[1]!));
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
const idsUnder = (keys: Set<string>, prefix: string) =>
@@ -68,6 +82,9 @@ describe("backend ids have their names in both languages", () => {
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0);
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
// Every code the frontend localizes is raised somewhere the scan can see.
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
for (const lang of ["en", "hu"]) {
@@ -99,5 +116,12 @@ describe("backend ids have their names in both languages", () => {
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
});
it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
[],
);
});
}
});
+1
View File
@@ -26,6 +26,7 @@
"created_one": "{{count}} clip added to your downloads",
"created_other": "{{count}} clips added to your downloads",
"failed": "Couldn’t create the clip.",
"partlyCreated": "Only {{done}} of {{total}} clips were added — the rest are still selected, so creating again adds just those.",
"splitHere": "Split here",
"output": {
"label": "Output",
+8
View File
@@ -20,6 +20,14 @@
"recipient": "There's no user with that email address.",
"job": "This job doesn't exist any more."
},
"admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
"not_applicable": "This account has nothing left to confirm.",
"not_approved": "Only an approved address can be invited.",
"email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.",
"send_failed": "The mail server didn't accept the message. Check Configuration → Email / SMTP, then try again.",
"demo": "The demo account is shared, so it can't get a set-password link."
},
"reason": {
"unreachable": "The server couldn't be reached. Try again.",
"forbidden": "You don't have permission to do that.",
@@ -165,7 +165,6 @@
"inviteLabel": "Send an invitation to {{email}}",
"invited": "Invitation sent to {{email}}.",
"inviteFailed": "Couldn't send that invitation.",
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
"inviteTitle": "Send invitation?",
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
+1 -2
View File
@@ -18,8 +18,7 @@
"resend": "Send the confirmation link again",
"resendHint": "Send the confirmation link again, to the address on the account.",
"resent": "Confirmation link sent to {{email}}.",
"resendFailed": "Couldn't send the confirmation link.",
"resendEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email."
"resendFailed": "Couldn't send the confirmation link."
},
"roles": {
"title": "Users & roles",
+1
View File
@@ -26,6 +26,7 @@
"created_one": "{{count}} klip a letöltésekhez adva",
"created_other": "{{count}} klip a letöltésekhez adva",
"failed": "A klip létrehozása nem sikerült.",
"partlyCreated": "{{total}} klipből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"splitHere": "Vágás itt",
"output": {
"label": "Kimenet",
+9 -1
View File
@@ -20,8 +20,16 @@
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
"job": "Ez a feladat már nem létezik."
},
"admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni.",
"not_approved": "Csak jóváhagyott címre küldhető meghívó.",
"email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.",
"send_failed": "A levelezőszerver nem fogadta el a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.",
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket."
},
"reason": {
"unreachable": "A művelet nem ment végbe: a szerver nem volt elérhető. Próbáld újra.",
"unreachable": "A szerver nem volt elérhető. Próbáld újra.",
"forbidden": "Ehhez nincs jogosultságod.",
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra.",
"unexpected": "Váratlan hiba történt az alkalmazásban. Próbáld újra."
@@ -165,7 +165,6 @@
"inviteLabel": "Meghívó küldése ide: {{email}}",
"invited": "Meghívó elküldve ide: {{email}}.",
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
"inviteTitle": "Küldesz meghívót?",
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
+1 -2
View File
@@ -18,8 +18,7 @@
"resend": "Megerősítő link újraküldése",
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
"resent": "Megerősítő link elküldve ide: {{email}}.",
"resendFailed": "Nem sikerült elküldeni a megerősítő linket.",
"resendEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt."
"resendFailed": "Nem sikerült elküldeni a megerősítő linket."
},
"roles": {
"title": "Felhasználók és szerepek",