BaseModal declared role="dialog" aria-modal="true" and then enforced none of it.
Focus never moved into the panel, so Escape — handled ON the panel — did nothing
at all in LabelsModal, the integration prompt and the shortcuts modal. Only the
command palette escaped correctly, and only because it happens to focus its own
input. Tab walked straight out of the dialog into the page that aria-modal had
just told assistive tech was inert, and closing dropped focus to <body> so the
next Tab restarted from the top of the document.
All three are one contract, so it lives in BaseModal rather than in each of the
four callers: focus in on open, Tab trapped, focus restored to the opener. The
panel takes tabindex="-1" so it can hold focus itself when it wraps nothing
focusable. CommandPalette's input focus still wins, because a child's mounted
hook runs before its parent's.
The skip link is the other half. The header and sidebar are a dozen-odd tab stops
that repeat on every navigation, and a keyboard user walked all of them again to
reach their notes. <main> takes tabindex="-1" as well, because several browsers
scroll to a bare anchor without moving focus to it — which would have made the
link look like it worked while leaving the next Tab back at the top.
The rest of the audit came back clean: no click handlers on non-focusable
elements, and all 30 focus:outline-none uses already pair with a focus-visible
ring. M3.5's keyboard pass held up; the gaps were in focus management, not
styling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay,
bordered rounded panel, role=dialog, close on Escape + backdrop mousedown).
Caller sizes/pads/shadows the panel via `panelClass`, picks start/center
`align`, and sets an `ariaLabel` for header-less panels.
- LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop
their hand-rolled backdrop+panel shells and slot their content into BaseModal
(~12 lines of overlay boilerplate each → gone).
- NoteEditor deliberately keeps its own shell: its backdrop mousedown is
drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety),
so folding it in would risk regressing the app's core editing surface (rule 28).
- AccountView's one device-name field now uses the shared BaseInput. SettingsView
is intentionally NOT converted — its rows are a horizontal label+control pattern
(checkbox/number/text, direct value mutation), a different shape than BaseInput's
vertical form field.
Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm