From dac391a89016ec0e255825e5323196f61d544963 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 02:22:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(e10):=20review=20round=203=20=E2=80=94=20se?= =?UTF-8?q?ttle=20the=20focus=20race=20in=20a=20browser=20instead=20of=20g?= =?UTF-8?q?uessing=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught is mostly the same shape as before — a guard narrowed until it was wrong in the other direction — plus one item this branch had now got wrong three different ways. Backspace ownership over the player, third and final answer. Round 1 deferred to nothing (the cheat sheet took the key and the player stopped underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's own gear/tracks popovers push layers too, so it stopped closing them). Round 2's fix named the sheet and palette (the app-wide ErrorDialog is neither, so the player tore down under it). The rule that actually holds is not a list of overlays: the player's own panels answer first, because handleBack's cascade is what closes them, and anything else layered above takes the key. The rail's inline rename is settled by measurement rather than argument. Two rounds reasoned about whether a React passive effect lands before or after floating-ui's return-focus microtask and reached opposite conclusions; a jsdom harness built to decide it reproduced no failure for ANY shape, because focus never enters the menu there — so it was deleted rather than kept as a test that discriminates nothing. Checked in a real browser instead: the field opens and holds focus. The shipped fix works, and the comment now says what was measured rather than what was inferred. The same check found that round 1's OTHER rail fix never worked at all. Escape does run its own handler (typing then Escape leaves the name unchanged, so it cancels rather than blur-commits) and the row button is focusable when focused from outside — yet focus still lands on . Three explanations, none of which survived. So the refocus machinery is reverted to the shape that shipped before this epic and the gap is documented where the next person will hit it, with the evidence and an instruction to instrument before theorising. A fourth guess would have been worse than a known bug. Also: the two comments in Playlists.tsx that still claimed the disabled menu rows explain themselves — untrue since round 2 reverted `inert`, and exactly the comment-vs-code drift CLAUDE.md calls the source of the worst bugs here. And the move-failure toast printed HttpError.detail verbatim, which for the reachable 404 is the backend's bare English "Unknown playlist"; only a structured detail has been through localizeDetail, so only a structured detail reaches the toast now. --- frontend/src/components/Playlists.tsx | 10 ++-- frontend/src/components/PlaylistsRail.tsx | 65 +++++++++++------------ frontend/src/components/PlexPlayer.tsx | 24 ++++++--- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 8f25f8ab..fa03016d 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -634,7 +634,10 @@ export default function Playlists() { })), } : // Kept visible and disabled rather than dropped: an action that vanishes teaches - // nobody why. With one playlist there is nowhere to move to, and the title says so. + // nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody + // either — see ActionMenu's `title` prop, where the two failed attempts at fixing + // that are recorded. The row is honest about being off; it is not yet honest about + // the reason. { id: "move", label: t("playlists.moveTo"), @@ -656,8 +659,9 @@ export default function Playlists() { label, icon, separatorBefore: i === 0, - // Same gate as the drag handle, and it explains itself the same way instead of - // silently doing nothing. + // Same gate as the drag handle. NOTE the drag handle explains itself and this row + // does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu + // row cannot be — see ActionMenu's `title` prop for why. disabled: !storedOrderEditable, title: storedOrderEditable ? undefined : dragHint, onSelect: () => moveInOrder(ctxRow, to), diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 4446efe0..3442f3c8 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -163,38 +163,42 @@ export default function PlaylistsRail({ }, }); - // Which row to hand focus back to once the inline editor closes. Enter and Escape both unmount the - // focused and re-render the row as a