- The dragged row trailed the cursor by 150ms. The row carried Tailwind's bare
`transition` utility (copied from DataTable's <tr>), which animates TRANSFORM
— exactly the property dnd-kit drives from the pointer. transition-colors now,
and the row being dragged drops dnd-kit's own transition too.
- The list looked sorted by Channel when it was really in its stored order that
happens to be channel-grouped, and nothing said so. Sorting by "#" ascending
IS the stored order, so it is now the neutral state: the header shows a real
arrow for it, cycling off any column lands back on it, and it stays the state
where drag is enabled and no save is offered.
- A choice dialog with two or more actions stacks vertically. Side by side they
wrapped raggedly, and the wrapped one read as a lesser second group when the
two are peers. Cancel goes last, actions run least to most destructive.
The column sort and the filters are a VIEW; the playlist's stored order only
changes when the user presses "Save this order", which still goes through the
undoable path. Picking a sort used to write straight to the server, so merely
looking at the list by title rewrote the playlist and marked a YouTube-linked
mirror dirty.
The table:
- playlistColumns.tsx holds the column defs; the body renders them as table rows
and fills the grip cell itself, since the handle needs the row's sortable
listeners that a render closure can't reach.
- The # column shows the STORED position, so a view-sort never renumbers the
playlist under the reader.
- Saving is withheld while a filter is active (it would drop the hidden items),
and drag is disabled under any view, with the reason spelled out rather than
silently doing nothing.
- applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the
channel grouping compose, and getting that wrong produces a plausible order
that the save button would then persist.
- dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder",
but only a pointer could move it.
The guards:
- Delete is ONE dialog with every outcome. It was two chained confirms where the
second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the
dialog deleted the playlist. ConfirmProvider gained useChoice() for it.
- ConfirmProvider focuses Cancel when an offered action is destructive; the
confirm button took autoFocus unconditionally, so a stray Enter on an open
dialog ran "Delete user" or "Clear audit log".
- Removing an item asks first, and rebases the undo history instead of throwing
it away: useUndoable.rebase maps every snapshot, so the reorder history stays
valid for the remaining items.
- A failed detail query showed "Loading…" forever; it now says so and offers a
retry. Rename and reorder failures are surfaced instead of swallowed (reorder
was a bare .then(), which also produced an unhandled rejection).
- Local reorder/remove no longer require YouTube write scope.
- playlistName() replaces the watch-later localizer copied into three files.
Add a promise-based ConfirmProvider + useConfirm() hook (built on the Modal shell,
liquid-glass styling, danger variant, Esc/backdrop = cancel, Enter = confirm) so
confirmations match the app instead of the native browser dialog. Wire it in at the
app root and replace both window.confirm call sites (playlist delete, channel
unsubscribe). Trilingual common.confirm/confirmTitle strings.