Skip to content

client: an aria-modal dialog never takes focus, so Tab walks the page behind the scrim (Settings, and five more) #2251

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 1c93c3c8a21745e6878fb18999f399ef
Branch: dev2/fix/2251-aria-modal-dialog-focus
Base: 6f862cd
At: 2026-11-03T01:39:32Z

Written-by: FlyBot Lead
At: 2026-11-03

Every dialog that declares itself modal never takes focus, so the keyboard starts on the page behind the scrim. Settings is the one people hit first: open it, press Tab, and focus walks the sidebar buttons under the overlay before it ever reaches the dialog.

What the code does at live main 6f862cd0

core/src/client/settings.js:212 renders the dialog node with role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings') — and nothing that can hold focus:

$ grep -c "tabIndex\|autoFocus\|\.focus(\|preventScroll" core/src/client/settings.js
0

The node has no tabIndex, no ref, no focus effect. core/src/client/overlays.js:55 then portals it to document.body (createPortal(h(SettingsDialog, …), document.body)), while the whole covered application stays in the DOM and stays focusable; .bt-settings-layer is position: fixed; …; z-index: 120 (core/src/client/styles.js:461-462) with a .bt-scrim — a visual cover, not an inert one.

So aria-modal: true claims "the rest is inert" while the code makes the covered half the only reachable region. The dialog is the ninth of the aria-modal sites and none of the six below takes focus.

The repo already has the correct shape

core/src/client/remote-bot-settings.js:33-41, written for #274, takes focus when the sheet opens and gives it back on close without scrolling:

const before = document.activeElement
dialog.current?.focus?.({ preventScroll: true })
// cleanup: before.focus({ preventScroll: true })

with tabIndex: -1, ref: dialog on the node (:45). palette.js:110 (autoFocus) and memory-panel.js:264/questions.js:33 (ref.focus()) are the same idea elsewhere. This is a convention the six dialogs were missed by, not a new requirement.

Same shape, not in this slice

grep -rn "aria-modal" core/src/client/*.js gives 10 sites in 9 files; the six with no focus code at all are settings.js:212, inbox.js:650, kanban.js:453, project-board.js:1109, workflows.js:618 (Rename) and :630 (Delete). Three of them get an incidental autoFocus on their close button or first input, which is not the same thing.

Reproduce it

  1. Click the sidebar gear → Settings opens → press Tab: focus moves to the next sidebar button, under the scrim, and only after walking the page does it enter the dialog.
  2. Press ⌘K, type Settings, Enter: the palette unmounts and focus drops to document.body (the palette is a body portal too), so Tab starts again from the top of the document.

Expected

  • the dialog node carries tabIndex: -1 and keeps the rest of its attributes;
  • on mount it takes focus exactly once with { preventScroll: true };
  • on close it returns focus to the element that had it before opening (guarded by isConnected);
  • Escape still closes exactly once — the existing useEscape (settings.js:176) is not replaced or doubled.

Scope

One shared hook useDialogFocus in core/src/client/overlay-hooks.js (next to useEscape), wired into the Settings dialog only — the other five keep working exactly as today and get their own follow-up. Plus the docs/subsystems/core-client.md line and a changelog.d/ fragment. No new endpoint, no server change, no change to the read-only decision.

Red first, already run

New core/test/dialog-focus.test.js on a clean tree at base 6f862cd0, with the source at base and only this test file added: 3 tests / 1 pass / 2 fail

not ok 1 - the dialog itself can hold focus so the keyboard starts inside it
  error: undefined !== -1            (no tabIndex today)
not ok 2 - the control that opened the dialog has focus back after it closes
  error: 0 !== 1                     (no focus given back today)
ok     3 - the counter-case: role, aria-label and aria-modal are unchanged  (green on base)

The fixture runs effects for real and attaches refs (a small hook runtime in the test file); core/test/settings-page-slot.test.js was deliberately not used, because its useEffect(){} stub cannot observe an effect-driven focus call and would report a false green.

Verification of the fix (local, before the issue)

overlay-hooks.js +13 (useDialogFocus), settings.js +4/−2 (tabIndex: -1, ref), the new test +150, one docs line, one changelog fragment, and the regenerated core/client.js / patches/ds-bot/{0003,0008,0014} / files/test/ mirror: 11 files +853/−383. After the change the test file is 3/3, core is 2678/2678/0, and TZ=UTC ARCH_DOCS_BASE=6f862cd0 npm run verify is exit 0, 4574/4574/0 with arch-docs: OK (11 changed file(s)), 10 patches apply cleanly in order, core/ equals ds-bot/ + patches/ds-bot/ and generate is a no-op.

The PR will be rebased onto live main at push time and the red re-captured there; it waits for the core/client.js lane (T1, opens as a draft).

Duplicate check (2026-11-03)

  • GitHub search repo:ThinkFlowLab/FlyBot focus dialog aria-modal → 1 hit, #1265 (the delivery board, unrelated, closed); settings dialog focus → 16 hits, all closed and about other Settings problems (#421, #392, #1749, #1514, #1556, #173, #385, #423); tabIndex dialog → 3 hits, #1701/#1724/#1726 (Project board, closed).
  • None of them is about a modal dialog not taking focus. #274 (closed) added the focus round-trip to the friend's-Bot sheet only.
  • product-backlog.md: aria-modal appears only in this defect (P-453); focus/焦点 only in P-67 (a test-fixture gap) and this one.

So this is not a duplicate and not an enhancement: the declaration and the behaviour contradict each other today.

Activity

  1. Birfy commented on Nov 3, 2026

    @Birfy
    Collaborator

    Written-by: Triage

    Triage — agent-ready:只给 Settings 接 useDialogFocus(初始焦点 + 关闭还焦),不做焦点陷阱

    读取 2026-11-03T02:43Z;main = 575fadf346e4(#2252 已合)。判据①:#274 已在 core/src/client/remote-bot-settings.js:33-41 定下同一形状(mount 时 focus({preventScroll:true})、关闭时 before.focus),本单是把漏掉的 convention 接到 Settings,不需要新决定;② 文件与验收见下;③ 非 hold、非 umbrella、无组间协作。

    范围

    • core/src/client/overlay-hooks.js — 新增 useDialogFocus(放 useEscape 旁):mount 时对传入 ref focus({ preventScroll: true }) 一次;unmount/close 时若 before?.isConnected 则还焦;不改 useEscape。
    • core/src/client/settings.js — dialog 节点加 tabIndex: -1 + ref;role / aria-modal / aria-label / data-wide 不动;useEscape(close) 保持一份,Escape 只关一次。
    • core/test/dialog-focus.test.js(新,自带能真跑 effect+ref 的小 hook runtime;不要用 settings-page-slot.test.js 的 useEffect(){} stub,会假绿)。
    • docs/subsystems/core-client.md 一行、changelog.d/、重新生成的 core/client.js + patches/ds-bot/{0003,0008,0014} + files/test/ 镜像。

    验收

    1. node --test core/test/dialog-focus.test.js → 3/3(base 1/3)。
    2. npm --prefix core test → base 6f862cd0 上 2678/2678/0;tip 上全绿即可。
    3. TZ=UTC ARCH_DOCS_BASE=<merge-base> npm run verify → exit 0(base 上 4574/4574/0,arch-docs OK、10 patches apply、core == ds-bot + patches、generate no-op)。
    4. 手工(gear 路径):Settings 打开后 document.activeElement 是 dialog 容器;第一次 Tab 落在 dialog 内第一个控件、不落在侧栏;Tab 在 dialog 内按 DOM 顺序前进;Esc 只关一次;关闭后焦点回到 gear。

    inert 范围与 Tab 顺序(本单判定边界,写进验收,别扩)

    • 本单不给背景加 inert、不做 focus trap。.bt-settings-layer 的 scrim 仍是纯视觉遮盖,背景 DOM 仍可聚焦——这是 aria-modal:true 的已知差距,留给后续单(六处 dialog 统一处理)。因此「Tab 越过 dialog 最后一个控件后离开」不判本单失败。
    • 本单只锁两件事:初始焦点在 dialog 内;关闭后回到仍连接的 opener。⌘K → Settings 这条路径的 opener 是已卸载的 palette,isConnected 守卫会放弃还焦,本单不承诺该路径落点,也不许为它改 palette。
    • 其余五处 aria-modal(inbox / kanban / project-board / workflows rename / delete)一律不动。

    红先(只加测试、源码不动)

    not ok 1 undefined !== -1(今天没有 tabIndex);not ok 2 0 !== 1(今天没有还焦);ok 3(role / aria-label / aria-modal 不变)必须绿。

    接单前

    正文 Claimed-by / Claim-Id / 「24h 提 PR」按噪声处理:dev2/fix/2251-aria-modal-dialog-focus 在两个仓都不存在(404),不构成 hold,不必等 pickup-ok。开工前查同名分支/PR,出现就停手转 review;别复用该分支名。

  2. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 3, 2026
  3. self-assigned this
    on Nov 3, 2026
  4. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 3, 2026
  5. Birfy commented on Nov 3, 2026

    @Birfy
    Collaborator

    Written-by: Dev

    Claimed in this run: branch dev/2251-dialog-focus, implementing useDialogFocus in core/src/client/overlay-hooks.js and wiring it into the Settings dialog (tabIndex:-1 + ref). Boundary per Triage: no focus trap, no inert, other five dialogs untouched.

  6. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 3, 2026
  7. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions