Problem
Opening the mobile drawer never moves focus into it. openDrawer in
frontend/src/app.ts picks its focus target with:
const firstFocusable = sidebar!.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
The first element in #sidebar matching that selector is
button.app-sidebar-toggle, the desktop collapse control. Below the drawer
breakpoint frontend/src/styles/responsive.css sets
.app-sidebar-toggle { display: none }, and .focus() on an unrendered
element is a no-op, so focus stays on the hamburger.
The selector is not filtered for whether the candidate is actually rendered at
the current width, so it selects a control that only exists on desktop.
Evidence
Measured in Chromium at a 390x844 viewport against the built bundle, after
clicking #hamburger-btn and confirming aria-expanded="true":
{"activeId":"hamburger-btn","activeTag":"BUTTON","focusInsideDrawer":false,
"firstCandidateClass":"app-sidebar-toggle","firstCandidateDisplay":"none"}
openDrawer is only ever reached from the hamburger click, and the hamburger
is display: none above the breakpoint, so this is the behaviour on every
open, not an edge case.
Impact
A keyboard or screen-reader user who opens the drawer has to tab through the
rest of the header before reaching the navigation the drawer just presented.
The documented contract in the setupMobileNav doc block ("Focus first
focusable link in the sidebar") is not met.
PR LeanerCloud/cloud-commitments-cli#1877 fixed a different defect on the widening path: it reused
closeDrawer() across the breakpoint, which marked the visible desktop
sidebar aria-hidden="true". That was a state-transition reuse bug in code
that PR introduced. This one is a pre-existing candidate-selection bug in
openDrawer, which PR LeanerCloud/cloud-commitments-cli#1877 does not touch.
The fix also carries its own test scope: any rendering check
(offsetParent, getClientRects().length) is unsatisfiable in jsdom, which
resolves no layout, so frontend/src/__tests__/mobile-nav.test.ts needs
reworking and the real assertion has to live in
frontend/tests-e2e/mobile-header-drawer.spec.ts.
Suggested fix
Skip candidates that are not rendered at the current width when choosing the
drawer's initial focus target, and assert in the Playwright suite that after
opening the drawer at a phone viewport document.activeElement is inside
#sidebar.
Findings from the 2026-09-02 codebase audit
Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.
A11-019 (low)
Two more focus-management defects, in the shared confirm dialog rather than the drawer. confirmBtn.focus() at frontend/src/confirmDialog.ts:84 runs for every dialog with no destructive branch, opts.destructive being used only for the button class at :72, so "Delete user", "Delete account" and "Execute Purchase Now" all open with the destructive button focused; several call sites open the dialog straight from a click, so a held Enter carries through (bulk delete at users/userActions.ts:163-168, direct execute at app.ts:352-357). Separately the Tab trap at :102-113 reads no shiftKey and always advances by +1 through cycle after an unconditional preventDefault, so Shift+Tab moves forward. Focusing cancel (or the close control when cancel is hidden) when destructive is set, and stepping backwards on shiftKey, fixes both. Finding A11-019.
A11-022 (low)
Two dialogs on the Settings page get none of the modal helper's behaviour at all. openAccountOverridesModal ends in modal.classList.remove('hidden') (frontend/src/settings.ts:1136) with closeAccountOverridesModal adding it back at :1140-1142, and openOverrideModal / closeOverrideModal do the same at :1657 and :1823-1825. openModal / closeModal are called exactly twice in that file, both for the account modal (:2042, :2439), so adjacent dialogs on the same page behave differently: the two that bypass the helper have no Tab trap, do not close on Escape and do not restore focus to the trigger, and a keyboard user can tab straight out of the open overrides modal into the page behind it. The helper at frontend/src/modal.ts:1-15 is what supplies all three. Finding A11-022.
Problem
Opening the mobile drawer never moves focus into it.
openDrawerinfrontend/src/app.tspicks its focus target with:The first element in
#sidebarmatching that selector isbutton.app-sidebar-toggle, the desktop collapse control. Below the drawerbreakpoint
frontend/src/styles/responsive.csssets.app-sidebar-toggle { display: none }, and.focus()on an unrenderedelement is a no-op, so focus stays on the hamburger.
The selector is not filtered for whether the candidate is actually rendered at
the current width, so it selects a control that only exists on desktop.
Evidence
Measured in Chromium at a 390x844 viewport against the built bundle, after
clicking
#hamburger-btnand confirmingaria-expanded="true":{"activeId":"hamburger-btn","activeTag":"BUTTON","focusInsideDrawer":false, "firstCandidateClass":"app-sidebar-toggle","firstCandidateDisplay":"none"}openDraweris only ever reached from the hamburger click, and the hamburgeris
display: noneabove the breakpoint, so this is the behaviour on everyopen, not an edge case.
Impact
A keyboard or screen-reader user who opens the drawer has to tab through the
rest of the header before reaching the navigation the drawer just presented.
The documented contract in the
setupMobileNavdoc block ("Focus firstfocusable link in the sidebar") is not met.
Why this is not part of PR LeanerCloud/cloud-commitments-cli#1877
PR LeanerCloud/cloud-commitments-cli#1877 fixed a different defect on the widening path: it reused
closeDrawer()across the breakpoint, which marked the visible desktopsidebar
aria-hidden="true". That was a state-transition reuse bug in codethat PR introduced. This one is a pre-existing candidate-selection bug in
openDrawer, which PR LeanerCloud/cloud-commitments-cli#1877 does not touch.The fix also carries its own test scope: any rendering check
(
offsetParent,getClientRects().length) is unsatisfiable in jsdom, whichresolves no layout, so
frontend/src/__tests__/mobile-nav.test.tsneedsreworking and the real assertion has to live in
frontend/tests-e2e/mobile-header-drawer.spec.ts.Suggested fix
Skip candidates that are not rendered at the current width when choosing the
drawer's initial focus target, and assert in the Playwright suite that after
opening the drawer at a phone viewport
document.activeElementis inside#sidebar.Findings from the 2026-09-02 codebase audit
Added by an automated audit of
3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd(tip oforigin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report:docs/audits/codebase-audit-2026-09-02.md.A11-019 (low)
Two more focus-management defects, in the shared confirm dialog rather than the drawer. confirmBtn.focus() at frontend/src/confirmDialog.ts:84 runs for every dialog with no destructive branch, opts.destructive being used only for the button class at :72, so "Delete user", "Delete account" and "Execute Purchase Now" all open with the destructive button focused; several call sites open the dialog straight from a click, so a held Enter carries through (bulk delete at users/userActions.ts:163-168, direct execute at app.ts:352-357). Separately the Tab trap at :102-113 reads no shiftKey and always advances by +1 through cycle after an unconditional preventDefault, so Shift+Tab moves forward. Focusing cancel (or the close control when cancel is hidden) when destructive is set, and stepping backwards on shiftKey, fixes both. Finding A11-019.
A11-022 (low)
Two dialogs on the Settings page get none of the modal helper's behaviour at all. openAccountOverridesModal ends in modal.classList.remove('hidden') (frontend/src/settings.ts:1136) with closeAccountOverridesModal adding it back at :1140-1142, and openOverrideModal / closeOverrideModal do the same at :1657 and :1823-1825. openModal / closeModal are called exactly twice in that file, both for the account modal (:2042, :2439), so adjacent dialogs on the same page behave differently: the two that bypass the helper have no Tab trap, do not close on Escape and do not restore focus to the trigger, and a keyboard user can tab straight out of the open overrides modal into the page behind it. The helper at frontend/src/modal.ts:1-15 is what supplies all three. Finding A11-022.