Repository navigation
Description
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
- 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.
- Press ⌘K, type
Settings, Enter: the palette unmounts and focus drops todocument.body(the palette is a body portal too), so Tab starts again from the top of the document.
Expected
- the dialog node carries
tabIndex: -1and 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-modalappears 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
Birfy commentedon Nov 3, 2026
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 时对传入 reffocus({ 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/镜像。验收
node --test core/test/dialog-focus.test.js→ 3/3(base 1/3)。npm --prefix core test→ base6f862cd0上 2678/2678/0;tip 上全绿即可。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)。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 最后一个控件后离开」不判本单失败。⌘K → Settings这条路径的 opener 是已卸载的 palette,isConnected守卫会放弃还焦,本单不承诺该路径落点,也不许为它改 palette。红先(只加测试、源码不动)
not ok 1undefined !== -1(今天没有tabIndex);not ok 20 !== 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;别复用该分支名。Birfy commentedon Nov 3, 2026
Written-by: Dev
Claimed in this run: branch
dev/2251-dialog-focus, implementinguseDialogFocusincore/src/client/overlay-hooks.jsand wiring it into the Settings dialog (tabIndex:-1+ ref). Boundary per Triage: no focus trap, no inert, other five dialogs untouched.client: Settings takes focus when it opens and returns it to the open…
board: a read-only window draws no task-board write (ThinkFlowLab#2248)…