- AppCommandPalette had no test: modules gated by the account, hidden channels left out, the
playlist row selecting before it navigates, and the surprise riding setPage's `then`.
- keymap: an Alt/AltGr-modified key (Ctrl+Alt on Windows, a constant on the HU layout) fires no
bare-key or Mod row.
- Banner: the dismiss button falls back to "Close" rather than going unnamed.
- ShareDialog: a protected link's lit lock and Revoke's danger colour stay on the icon.
- Both playlist headers are rendered now: the YouTube one needed the toolbar slot it portals into.
Meta line sums known runtimes and treats an ABSENT duration as nothing (null never exercised the
guard: `3000 + null` is 3000); a one-row list offers no view controls.
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.
- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.
npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
Third and last slice of the E11 keyboard layer (S4c). S4a/S4b covered every card grid and rail; the
table-shaped pages — the channel manager, a playlist, the download center — were still Tab-stop
country: a channel row carries a priority stepper, four action buttons and a tag picker before the next
row's name.
- `ui/DataTable keyboardNav`: the rows are the items, Enter clicks the element a column marks
`data-nav-open`. DataTable renders its rows twice (a table, and a card list below `md`); both register
and `lib/listNav` now walks only lists on screen (`checkVisibility`), so `j` past the last row never
carries on into the hidden copy.
- `lib/listNav` leaves a key something before it already handled (`defaultPrevented`): during a keyboard
drag a playlist row's grip moves the row with the arrows, and dnd-kit takes the key on the document
before listNav's window listener. Without the guard ↓ moved the focus off the grip and dropped the drag.
- `ui/NavGrid as="tbody"` for a table whose body is the list (the playlist, under dnd-kit's
SortableContext).
- Wired: Subscriptions and Discovery tables, the card layout (`ChannelLayoutGrid` → VirtualGrid
`keyboardNav`), Hidden/Blocked cards (the ChannelLink name is a channel's open element); a playlist's
rows (the title plays; the grip keeps Space/Enter); the download queue, library and shared lists (the
play button opens, `IconBtn open`; a row without one opens nothing).
- `listNav.wiring.contract.test`: `VirtualGrid` and `DataTable` must say `keyboardNav` like `Carousel`,
`DownloadRow` is a card; the audit log's table is the one exemption (its rows open nothing), and an
exemption that no longer matches a tag fails the test.
Measured in Chrome: Channels table j → row, ring round the whole row; a row walked down keeps 12px above
the viewport edge, walked up clears the sticky header by 35px; Enter opens the channel. Cards 3 columns:
→ 0→1, ↓ 1→4. Hidden and Discovery: j + Enter open the channel. Playlist: j + Enter plays, Escape returns
focus to the row; a keyboard drag (Enter on the grip, ↓) moves the row with focus kept on the grip, and
Escape cancels with the order unchanged. Downloads library: Enter plays a video row, does nothing on a
plain file row.
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.
- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
onError, so it waits a task and skips a failure the site already reported; the toast title comes
from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
structured localized detail, else the status). It marks the error reported (`markReported`), so
nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
Deep-review round 5, over round 4's own fixes.
- Round 4 made a failed reorder roll the rows back to the CACHED detail, because a resync cannot do
it (the server's list is unchanged, so the refetch is deep-equal, react-query keeps the same
`data` object and the effect keyed on it never runs). But nothing wrote an ACCEPTED order into
that cache — only the refetch that follows a successful reorder did. So a second reorder rejected
while that refetch was still in flight rolled the rows back past the first one, which the server
had accepted; the membership guard cannot correct an order (its key is the sorted id set), so the
wrong order stayed on screen for good and the next drag persisted it. `onApply` now writes the
accepted order into the cache before invalidating.
- `PlexPlaylistView.rename` had a `catch` no test could reach, and `commit` (reorder) was not gated
by `busy` the way its siblings are — a reorder in flight while a removal completes is what makes a
rollback ambiguous in the first place. The rename failure path is now covered; `commit` defers.
- `putBack` read `q.data` from the render that bound the failing handler instead of the cache at
rollback time, unlike the sibling `restoreFromCache`. Aligned. Stated plainly: I could NOT drive
that staleness in jsdom — a mutation that restores the old shape leaves every candidate test green
— so this one ships as an alignment with the proven sibling, not as a fix with a red test behind
it. The scenario and the harness limit are on ROADMAP.md.
Two regression tests, both mutation-checked red (the cache write, the rename catch). The
drag-reorder failure path still has no test: dnd-kit's pointer and keyboard sensors do not activate
under jsdom even with geometry and scrollIntoView stubbed, so a test there would have pinned the
harness, not the code. Logged, not faked.
vitest 1009.
Deep-review round 4, the playlist surfaces.
- A failed move or reorder relied on the resync to put the rows back — but the server's list is
unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the
re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on
screen. Both restore from the cached detail now.
- A failed removal puts its row back itself: when the server is unreachable the resync fails too,
and round 3's "keep the list on a failed refetch" guard then kept showing it as removed.
- The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the
tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a
structured `not_found` the request layer leaves to the caller) failed in silence and an
optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is
already gone just leaves the page.
- Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading
line had no live region).
- The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up.
Deep-review round 3 of bug hunt 2026-09.
- The detail's error branch applies only when there is nothing to show (`isError && <no data>`, the
QueryState convention): one failed refetch — on focus, after a move or a removal — replaced a
loaded list with "couldn't load". The error line is announced (role=alert).
- Tests for the two branches of "the player survives a change of selection" that round 2 left
untested (a list that fails to load, no selection).
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
selected list: launched from Watch later (no Remove, by design), selecting another list under it
made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
"Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
`removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
whether or not the removed one left the queue; it now takes the second step that tells them apart.
The D1 regression test patched the cache and clicked at once; react-query delivers a cache write to
its observers on a setTimeout(0), so under the gate's parallel load the click could beat the page's
re-render and read the old row (748, not 830). Test-only race — a human reopen comes seconds later.
Stable across three full-suite runs; the D1 mutation still turns it red.
Deep-review R5 — five defects in the Remove button f76a2afb put inside the YT player:
- It removed from whichever playlist was selected by the time it was pressed, not the one the player
was launched from; the selection can change under an open player (the command palette opens over
it). The launch list is now part of the player snapshot, and the optimistic row removal only
touches the list actually on screen.
- A 403/404 failed in total silence: the catch claimed the global dialog had reported it, but `req`
deliberately leaves those to the caller. It now notifies, as moveItemTo already did (the silent
catch predates this round; the full-screen consumer did not). New key playlists.removeFailed, HU+EN.
- It computed the next item from the cursor as it was when the button was pressed, but prev/next and
auto-advance keep working under the confirm dialog: the viewer was thrown back onto a video they had
moved past. It reads the live cursor, and if they moved on it just drops the item from the queue.
- The button was disabled for the confirm's lifetime, so the dialog's focus restore hit a disabled
element and a keyboard user who cancelled was left on <body>, outside the aria-modal player. A ref
guards the double press instead.
- Its accessible name was a bare "Remove"; it now names its object, like the row control.
The call-site wiring (step on, drop from the queue, close on the last item, leave everything alone
when the removal did not happen) finally has tests. Verified on localdev in the real YT player: a row
reopened at the 136 s it had just stored; Cancel returned focus to the Remove button; a confirmed
removal stepped 1/3 -> 1/2. 10 mutations, each red on its assertion. Scratch list and positions reset.
Deep-review R4. 1630a3df wrote the YT player's stored position into the react-query cache so a
reopen could not rewind it — but only the feed actually read it back:
- Playlists renders from its own undoable copy of the list, re-seeded only when the SET of ids
changes, so the patched position never reached it: close a video at 830 s, reopen it from the
playlist, and it resumed at the old spot and the next checkpoint wrote that over the 830. A detail
with the same membership now rebases its fresh rows into every snapshot, keeping order and undo
history. (The ids-only guard is older than this round; the write that depended on it was new.)
- Live YouTube search results carry the same per-user position and open the same player, but their
cache was never patched. It is now — patched only, never invalidated, since refetching an
API-sourced search page spends 100 quota units.
Tests for what had none: a Playlists harness (PlayerModal stubbed to record its launch props) and a
PlayerModal harness with a fake IFrame API that records where each item is started — the latter pins
that stepping back to an item watched earlier in the same session resumes at THIS session's position.
3 mutations, each red on its assertion.