fix(website): keyboard boundary for the picker portal above a Modal - #8496
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of All evidence reviewed — the diff is a single behavioral guard (one UX-Verdict: PASS Pure loss-prevention fix: a mistyped global chord in the picker no longer silently destroys the folder dialog's draft, matching the boundary every Modal already has. The chord now no-ops inside the picker exactly as it already does inside every [UX-REVIEWED] 9183954 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Guard sits at the right ownership layer — the sibling overlay owns its own boundary — bounded, visually inert, control-and-mutation-verified, with the class contract written down. Watch
Suggestions
[DESIGN-REVIEWED] 9183954 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks complete. First-Principles-Verdict: CONCERNS The fix earns its place at cause level, but the description rejects the smaller re-parent fix with a rationale the PR's own doc contradicts. What this change shipsIntent: stop a global chord typed in the project picker from destroying the folder dialog and its draft — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 9183954 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Disposition of both reds on
|
…oundary Modal's keyboard boundary (#6832) is a bubble-phase handler on the dialog PANEL, so it covers the Modal's own React subtree. FolderConfigModal renders ProjectPicker after </Modal>, making it a React SIBLING, and React routes synthetic events along the React tree - so Modal's handler is never an ancestor on the picker's dispatch path. A Ctrl+digit typed in either picker field therefore reached useKeyboardShortcuts' bubble-phase document listener, navigated away, and unmounted the dialog with its part-filled folder draft. Adds the same surgical guard to the picker's portal root, reusing the component's existing IME guard rather than mounting a third document-tracked latch. Escape stays excepted, so bubble-phase dismissal keeps working. Also writes the contract down: coverage follows the REACT tree, not the DOM tree and not the stacking order. Both overlays in FolderConfigModal portal to document.body at the same z-[9999]; only one is inside the boundary. Treating a shared stacking context as an event-routing fact is what kept this open. Refs #6833
…boundary Captured from a harness mounting the real FolderConfigModal (real Modal, real ProjectPicker as its React sibling). The only stand-in is the chord SINK: the page's real one is useKeyboardShortcuts' bubble-phase document keydown listener which navigates and thereby unmounts the dialog; the harness reproduces it at the same target and phase. The capture script asserts its own outcome and exits non-zero on a mismatch, so a stale bundle cannot yield a confident screenshot of the wrong thing: before -> jumped=1 dialogs=0 after -> jumped=0 dialogs=1 draft='/home/u/projects/kirocrew' Refs #6833
2fa2b3b to
9183954
Compare
What is the problem?
Modalgained a keyboard boundary in #6832 so the page's global shortcuts cannotfire from inside a dialog holding unsaved input. That boundary is a bubble-phase
onKeyDownon the dialog panel (ModalDialog.isolateKeysinwebsite/src/components/Modal.tsx), so it covers the Modal's own Reactsubtree.
FolderConfigModalrenders<ProjectPicker>after</Modal>, so the picker isa React sibling of the dialog, not a descendant. React routes synthetic events
along the React tree even across a portal, so
ModalDialog's handler is never anancestor on the picker's dispatch path and never sees its keystrokes.
A global chord typed in either picker field therefore reached
useKeyboardShortcuts' sink — a plaindocument.addEventListener('keydown', handler)with no capture flag — which navigates away and unmounts the dialogwith the part-filled folder draft still in it.
Verified at
d0361761459476b8678e4fc864ee4673211ce2d3by component and function,not by line, because the line numbers in this issue's own analysis had already
shifted before this branch was cut (
:355/:361→ the</Modal>/<ProjectPicker>pair) in a file nobody edited in between:FolderConfigModalrenders<ProjectPicker>after</Modal>document.bodyProjectPicker→createPortal(…, document.body)Modal.tsx→ModalDialog→isolateKeysdocumentuseKeyboardShortcuts.ts→useKeyboardShortcutsuseDialogFocusTrap→container.contains(active)Visual evidence
Captured from a harness mounting the real call site — real
FolderConfigModal,real
Modal, realProjectPickeras its React sibling. The one stand-in is thechord sink: the page's real sink is
useKeyboardShortcuts' bubble-phasedocumentkeydown listener, which navigates and thereby unmounts the routeholding the dialog; the harness reproduces a listener at the same target and phase
that unmounts it directly. Mounting the whole router/redux/query shell would add
nothing to the frame, and the property under test is only whether a keystroke
inside the sibling portal reaches a bubble-phase document listener at all.
Shared starting state — the dialog with a folder name typed, and the picker
open above it at
z-[9999]with a path draft in its free-text field:Before — Ctrl+3 in the picker's path field destroys the dialog and the draft:
After — the same chord is inert; dialog, picker and draft all survive:
The capture script asserts its own outcome and exits non-zero on a mismatch,
so a stale bundle cannot produce a confident screenshot of the wrong thing —
beforerequiresjumped=1 dialogs=0,afterrequiresjumped=0 dialogs=1 draft="/home/u/projects/kirocrew". Both asserted OK with zero page errors.Note that the resting frames are identical by design: this fix changes what a
keystroke does, not what anything looks like. The discriminating frame is the one
taken after the chord.
Keyboard sequence tested
Screenshots are the weaker half of the evidence for a keyboard fix. These are the
assertions an image cannot carry, each pinned by a test:
(
useDialogFocusTrap, window capture) hits itsrefocusesbranch preciselybecause the active element is outside the dialog container, so it reclaims focus
into the dialog. Asserted: after Tab,
[role=dialog]containsdocument.activeElement.Tab back before it can land there. The picker's own controls are mouse-activated
via
onMouseDown+preventDefault(), so they never take focus either.from both fields (Recent search and Browse path); the dialog stays open with its
draft. An IME-owned Escape dismisses neither — mid-composition it is
cancelling a candidate list.
Browse…anchor inside thedialog, not on
<body>. Asserted with a realanchorRef, because landing on<body>would drop the user out of the dialog and restart the next Tab from thetop of the page.
keys the overlay itself needs.
documentlistener still receives the chord — the railagainst "hardening" this into a capture-phase guard.
Why this issue matters to the user
Configuring a folder means typing a path. The Browse field is free text, and the
Recent search field is where focus lands the instant the picker opens
(
autoFocus, and Recent is selected whenever any recent project exists). Onemistyped Ctrl+digit in either did not beep or no-op — it jumped to another
session, taking the dialog and everything typed into it. That is silent data loss
on the exact surface where the user is mid-edit, and an accessibility defect
independent of any styling question, because a keyboard user has no way to know
the chord is live there.
How our fix solves it
This EXTENDS the isolation. It does not re-parent the overlay. The
createPortal(…, document.body)call is untouched, thefixed z-[9999]positioning is untouched, and the picker stays exactly where it was in the React
tree. Re-parenting would have moved an anchored popover into the dialog's stacking
and focus context and changed dismissal semantics at the call site; that is a
layout change with visual consequences and it is not what this does. The entire
behavioural change is one
onKeyDownprop on the picker's own portal root.Chaining from symptom to root cause:
documentlistener.handlers claim only navigation keys (
useListKeyboardNavtakesEscape/Enter/Tab/arrows; the Browse field's
onKeyDowntakes the same set), sokey === '3'falls straight through.overlay is a React sibling of it.
Three properties are load-bearing, and I had one of them wrong at first:
receiving keys:
useListKeyboardNav(document capture) drives the Recent listand
useDialogFocusTrap(window capture) is the Tab trap. A guard hoisted tocapture phase would pass a naive test while killing both.
Escape before this handler runs, so the exception changes nothing observable
now — it protects the contract, since
stopPropagation()on a synthetic eventstops the native event too and bubble-phase
windowis exactly where Modal'sdismissal listens. A blanket-stop mutant passed every other assertion in the
new test file, so the file now pins the window-bubble property directly. My
first version of this comment claimed a blanket stop would strand the popover
open; the mutation disproved that and the comment was corrected to what was
measured.
useImeGuardinstance instead of mounting anotheruseDocumentImeLatch. Thisissue flags latch proliferation as a real cost of this fix shape; this avoids
adding to it.
Scope correction, independently reproduced. This issue names two members;
there is one.
SimpleSelectrenders before</Modal>, so it is already a Reactdescendant and already covered. I surveyed every
<Modal>file for overlaysoutside a
<Modal>span, with a control requiringProjectPickerOUTSIDE andSimpleSelectINSIDE — and the control failed twice first, because my matchermissed
<Compat end-of-line, which is how both real call sites are written. Thesurvey was therefore skipping the very file in question while returning a
clean-looking negative. Once the control passed, the only other candidates were
InfoTip(a tooltip:{open && createPortal(…)}, no input, notabIndex, noonKeyDown, so no keystroke can originate in it) and page-levelSimpleSelectfilters in
pages/knowledge/index.tsxthat sit behind the only modal's backdrop.One member with keystroke exposure.
Why two documentation files, in a focus-trap fix
Fair challenge, so here is the accounting for the 432 added lines:
website/src/test/ProjectPicker.keyboardIsolation.test.tsxwebsite/docs/frontend-conventions.mdwebsite/src/components/ProjectPicker.tsxwebsite/docs/README.mdThe second "documentation file" is a single line: that README is an index with
one contents row per doc, so adding a section without updating it leaves the index
stale. It is part of the same edit, not a drive-by.
The 85-line section is the deliberate part, and it is the convention case, not a
cleanup: #6832 shipped this boundary with no contract written down anywhere.
Zero
.mdhits in this repo for keyboard-isolation / isolation-boundary /global-chord — every match was sandbox and Docker text. Nothing stops the next
overlay from landing beside a
</Modal>and reintroducing this exact defect, andthe reason it stayed open this long is that the two overlays in this one dialog
share a portal target and a z-index while only one is covered. So the section
states the rule other portals must follow — coverage tracks the React tree, not
the DOM tree and not the stacking order — with those two overlays as the worked
example. If you would rather review the fix alone, say so and I will split the
docs into their own PR; I have not done so unprompted because the contract is
what stops the recurrence, and the fix without it is one instance of a class.
What tests we did
website/src/test/ProjectPicker.keyboardIsolation.test.tsx, 12 cases, openingwith a control pair because every other assertion is a negative: the same
chord fired inside the Modal body must be stopped, and a chord fired outside every
boundary must be seen. Without both, a green "not called" could just mean the
harness never delivered the key.
cases, both controls green. After: 12/12.
mutant (no Escape exception) reddens the window-bubble case. That second mutant
survived the first version of the suite, which is why that pin exists.
ProjectPicker×3,
FolderConfigModal,Modal×3, andImeEnterGuardSites— the IME-claimratchet my new claim site could have tripped), plus 28/28 in
ChatPageMoreCoverage, the only other file that drives real picker elements(every
ChatSidebartest mocksProjectPickerto() => null).eslint src/ --max-warnings 0clean. The guard first trippedjsx-a11y/no-static-element-interactions; resolved the way this repo alreadyhandles an event-catching container — the disable-with-justification used at
CommandPalette.tsx's stopPropagation barrier — rather than inventing a rolethat would advertise an interaction the element does not have.
i18n:checkPASSwith
I18N_BASE_REF=origin/mainset so the diff-scoped checks actually ran.lint:i18n,lint:phantom-classes,lint:theme-colors,jscpdPASS.tsc -breports exactly 1 error,ShareMessageModal.tsx/html-to-image, byte-identical on the pristine maincheckout — the package is in
package.jsonand the lockfile but absent fromnode_modules. Zero type errors outside it.Any other suggestions on the work
enumerates three mutually exclusive designs and carries
needs-human. Iimplemented option 2 because it is bounded and visually inert, and did the
documentation work every option needs. Filed as
Refs #6833rather than aclosing keyword, so the titled scope stays open for that call.
prose only; the survey in this PR is essentially the rule's logic.
useDialogFocusTrapexporting its
useDocumentImeLatchso Modal's boundary reuses it (Opus 5advisory, declined in fix(website): stop global chords at the shared Modal panel (#6800) #6832 as a public-API change) is unaffected here — I
avoided adding a third latch but did not consolidate the existing two.
InfoTipis the class's boundary case. It portals atz-[9999]and doesrender outside
<Modal>spans; it is excluded only because it takes nokeystrokes. If it ever gains a focusable control, it joins the class.
Pattern harvest
Rule candidate: eslint
Pattern: a component calling
createPortalrendered as a JSX sibling of<Modal>in the samereturn, with noonKeyDownon its portal root — thesibling position is what puts it outside the dialog's keyboard boundary, and it
is statically detectable. The control-bearing survey in this PR is the rule's
logic; it found exactly one such site.
overlays here portal to
document.bodyat the samez-[9999]; one is inside theboundary and one is not, and the difference is React-tree position. The mix-up
traces to a source comment that made a correct paint-order claim which was then
read as an event claim.
reported a tidy "only
InfoTipis affected" while silently skipping the one fileeveryone already knew was affected, because
<Comp[\s/>]does not match<Compat end-of-line. State what a positive looks like, then require the method to
produce it before believing any absence.
The chord fix was pinned immediately; the Escape exception was not — a blanket
stop passed all nine tests. A branch that is unreachable by focus needs a pin at
the contract level (does the key still reach the phase a future dismissal would
use?) rather than the behaviour level.
this fix could not be screenshotted because before/after would be identical
images. True of the resting state, false of the state after the keystroke —
which is the whole point of the fix. Capture the frame after the input, not the
component at rest.
implies the overlay's dismissal competes with the modal's; measured, it does not,
and focus does not leak into the overlay either — the trap actively reclaims it.
Both are now pinned so the correct behaviour cannot silently regress into the
behaviour the issue described.
Refs #6833