`confirm-dialog` is one singleton for every confirmation in the app,
and `wa-dialog` reports its close asynchronously: `open = false` starts
an animation and `wa-hide` arrives after it. So a hide belonging to a
question already answered can land after the *next* question has
opened, and cancel it — the user is asked something, the dialog
vanishes on its own, and the call site is told they said no.
Each ask now carries an id. `close` ignores an id that no longer names
the question on screen, the button handlers pass none (they always mean
the current one), and only the `wa-hide` handler carries one, because
only `wa-hide` can arrive late.
Found by writing two `confirmAction()` tests in one file: the second
could not be accepted at all, because the first one's hide had
cancelled it before the click landed. Reaching it in the app needs two
confirmations close together, which the album page's "Apply tags" makes
possible.
Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".
utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.
Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
There was no app-level notification surface: two components had grown
private toasts and the other 84 catch blocks ended at console.error,
so a user with a moved file, a locked database or an offline network
saw a button that did nothing. Where errors did surface, eight sites
printed the raw Go string.
Four levels, chosen by the call site from one rule — a failure is only
worth interrupting for if the user can do something about it that they
are not already doing: Blocking (data at risk), Persistent (something
asked for that did not happen, worth retrying), Transient (a small
action whose state visibly reverted anyway), Inline (rendered in the
panel that failed).
Three things about it are load-bearing. Coalescing lives in the store,
keyed by (level, region, key) within a window, so 200 unplayable files
are one message with a count and no future caller has to remember that.
An inline notification carries a *region*, because "inline" says not
global, not where. And the bottom band belongs to the player, so the
app-level stack sits under the header — the player's own floating
notice grows upward by however many lines it needs.
`utils/describe-error.ts` maps the causes a user can act on to copy;
`explainError` repeats a backend message when it is one of our own
sentinels rather than a Go wrapping chain, since mapping "a library
with that name already exists" to something generic is a regression.
`confirmAction()` is a wa-dialog, so destructive actions inherit the
focus trap and Escape the hand-rolled overlays do not have.