Status: fixed
What to build: The card text bottom sheet added in ticket 15 has role="dialog" and aria-modal="true", but static/editor.js does not move focus into it, trap Tab navigation, or return focus to the triggering card row. On a narrow editor, open a card sheet and press Tab: focus remains in and continues through controls behind the backdrop, allowing deck edits or navigation while the dialog is visually modal.
Implementation requirements:
openCardSheet opens the sheet, record the invoking row and focus #card-sheet-close.Escape, the close button, and backdrop clicks must all close the sheet and restore focus to the invoking row when it is still connected.(hover: none), (max-width: 700px) activation policy.
Tests: add focused static or browser-level coverage for the focus-management hooks. If browser automation is unavailable, ensure the test documents and asserts the open-focus, trap, close, and restore behavior in editor.js, then verify manually in a browser when available.
Definition of done:
#card-sheet is open.uv run pytest -q passes from the repo root.
2026-08-02: Filed from the review of ticket 15. The UI currently has no focus management despite declaring aria-modal="true"; this ticket completes that dialog contract.
2026-08-02: Shipped in static/editor.js and static/editor.html.
Opening. openCardSheet(card, opener) now takes the row that activated it, stores it in sheetOpener, marks .editor-wrap inert and focuses #card-sheet-close. Inert is what makes aria-modal="true" true of the page rather than only of the accessibility tree — it takes the editor out of reach of the pointer and the caret as well as of Tab. It is safe here because the sheet and its backdrop are siblings of .editor-wrap, never descendants; a test counts the div nesting between the two to keep it that way.
Trapping. One document-level keydown listener, armed only while the sheet is open. Escape closes; Tab and Shift+Tab wrap between the first and last of cardSheetFocusables(), which is #card-sheet's own querySelectorAll filtered to enabled, non-negative-tabindex, non-aria-hidden elements. There is one close button in the sheet today and the trap does not know that: it reads both ends off the DOM, so anything added to the sheet later is trapped with no list to update. Focus that has escaped the dialog by some other route is pulled back on the next Tab too.
Closing. All three paths — Escape, the close button, the backdrop — call closeCardSheet(), which clears inert *before* restoring focus (focus() inside an inert subtree is a silent no-op) and only focuses the opener when opener.isConnected, since renderDeck/renderBrowser rebuild rows wholesale and the row may be gone.
Reaching a row. Where the (hover: none), (max-width: 700px) query matches — unchanged, and still the only place any of this is wired — each readable row gets tabIndex = 0, role="button", aria-haspopup="dialog", aria-label="Read card text: <title>", a :focus-visible ring, and an Enter/Space handler beside the existing click handler. Both share the closest('button, input, select, label, a') bail, so pressing Enter on +/− is that button's business and row activation never calls changeQty. The +/− buttons stay focusable, which exempts them from the presentational-children rule inside the row's button role. Desktop is untouched: the early TAP_TO_READ.matches return still comes first, so pointer devices keep the hover tooltip and gain no tab stops.
Tests: 134 pytest (128 baseline + 6), green. Not rendered — no browser or JS runtime in this environment, so per the ticket's fallback the new tests assert the open-focus, trap, close and restore hooks against editor.js (which element is focused on open, that the trap ends are derived rather than hardcoded, that every close path routes through the one restore, and the inert-before-focus ordering). Worth a real keyboard pass on a narrow window when a browser is available.