LearnNewsExamplesServices
Frontmatter
id19339
titleA rail reveal's inside mousedown refocuses the overlay root even when the click lands on a text field
stateClosed
labels
bugaicore
assigneesneo-fable-clio
createdAtSep 30, 2026, 11:55 PM
updatedAtOct 1, 2026, 12:41 AM
githubUrlhttps://github.com/neomjs/neo/issues/19339
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 1, 2026, 12:41 AM

A rail reveal's inside mousedown refocuses the overlay root even when the click lands on a text field

neo-fable-clio
neo-fable-clio commented on Sep 30, 2026, 11:55 PM

Context

Operator report on the installed Fleet Manager, 2026-09-30 (screenshots on record): in the cockpit's Add agent pane — an auto-hidden rail item revealed as an overlay — clicking into the GitHub username field loses focus at once; the field then shows a light invalid border with a Required line that overlaps the next field's inline label, and while typing the whole pane wears a golden frame. Registering a new agent, the first thing an outside operator does after installing, is not possible by mouse.

Reproduced in the served cockpit at Institution d058a63 / this engine's dev: after a click on the input, document.activeElement is the overlay root (neo-dashboard-dock-reveal-pane-slot's ancestor), the field carries neo-invalid neo-is-touched one second later, and Required renders under it.

The Problem

Neo.dashboard.dock.interaction.RevealOverlay#onMouseDown (src/dashboard/dock/interaction/RevealOverlay.mjs:525) answers every mousedown inside a revealed overlay with this.focus(this.id, false, true, 'pointer'). Its own JSDoc names the intent — keep the focus-hold contract alive "even when its target (prose, whitespace, a plain container) cannot receive focus". The handler does not check the target. A mousedown on an <input> lets the browser focus the input as its default action; the programmatic root focus travels main → App worker → DomAccess.focus and lands after that default, so the input loses focus on the same click that gave it. Every text field, textarea, select, button or contenteditable inside a revealed pane is affected; the Add agent form is where an operator meets it first.

The two visible follow-ons are consequences, not causes: the blur validates the empty required field (neo-invalid, the Required line), and the golden frame is the overlay root's own focus ring.

The Architectural Reality

  • RevealOverlay.mjs:222 registers {mousedown: me.onMouseDown} as a global DomEvents listener on the overlay root; onMouseDown(data) (:525) ignores data.
  • The main-thread payload already carries what the guard needs: DomEvents#getTargetData (src/main/DomEvents.mjs:392) serializes tagName (lowercased), isContentEditable and tabIndex (a number only when the attribute exists) for data.target and every data.path entry.
  • onFocusLeave (:548) documents the contract the refocus protects: "Inside mousedown refocuses the root before the focus manager's leave window settles" — a click on a focusable descendant keeps focus inside the subtree, so the manager fires no leave and the contract holds without the refocus.
  • test/playwright/unit/dashboard/DockRevealOverlay.spec.mjs:180 pins the current behavior with an empty payload (overlay.onMouseDown({})); nothing pins the focusable-target case.
  • The consumer-side error-line overlap (Required over the next inline label) is the Institution's AddAgentForm.scss margin: 0 on the fields; it is a separate ticket there.

The Fix

In RevealOverlay#onMouseDown, refocus the root only when nothing on the event path can take focus itself: skip when data.target or any data.path entry has tagName in input, textarea or select, or isContentEditable, or a non-negative tabIndex. Prose, whitespace and plain containers keep today's behavior — and so does a plain button: not every browser focuses a clicked button, and a button never needs typing focus, so the root refocus after a button click stays (harmless); a button that carries an explicit non-negative tabindex falls under the tabIndex rule like any other element. JSDoc states the rule in one sentence.

Spec: DockRevealOverlay.spec.mjs keeps the empty-payload arm and gains one — a payload whose target is an input (and one whose path holds a textarea under a span, a contenteditable, a tabindex="0" target) records no focus call; a div target with prose on the path, a plain button target and a tabindex="-1" target still do.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
RevealOverlay#onMouseDown(data) this ticket; the focus-hold contract in onFocusLeave's JSDoc refocuses the root unless a target or path entry takes focus itself: input, select, textarea, isContentEditable, or a non-negative tabIndex (an explicit tabindex on a button included); a plain button, prose and containers keep the root refocus an event without target/path (as today's spec passes) still refocuses the root method JSDoc DockRevealOverlay.spec.mjs: one new arm red on dev (four skip cases, three refocus cases), the existing arm unchanged

Acceptance Criteria

  • AC-1 Unit: a mousedown payload whose target is an input, whose path contains a textarea, whose target is contenteditable, or whose target carries tabindex="0" records no focus call; a div target with prose on the path, a plain button target, a tabindex="-1" target and an empty payload still record [id, false, true, 'pointer']. The new arm is red on dev.
  • AC-2 Served cockpit: one click into the Add agent pane's GitHub username field leaves document.activeElement on the input and the field carries no neo-invalid afterwards (a read on the dev server, receipt in the PR).
  • AC-3 A click on the pane's prose still focuses the overlay root (the focus-hold contract for non-focusable targets is unchanged).

Out of Scope

The Required line overlapping the next field's label (Institution, the form's field rhythm); the overlay focus ring's color and weight (a design read of its own); hover-born reveals.

Avoided Traps

  • Cancelling the mousedown default instead (the GridDragScroll class of fix, #18065's lesson in reverse): it would stop text selection and the input's own focus.
  • Checking event.target on the main thread inside DomEvents: the decision belongs to the overlay, which owns the contract; the payload already carries the fields.
  • Refocusing after a delay: it would still steal focus from a control the user clicked.

Related

#18065 (a mousedown default cancelled by an addon kept focus from moving — the cousin), #18067 (iframe clicks inside a reveal), neomjs/neo-agent-institution#245 (the Add agent form's owner), neomjs/neo-agent-institution#351 (the first-run epic this pane serves)

Live latest-open sweep: the latest 20 open engine issues read at 2026-09-30T21:20Z (#19335 … #19135); no equivalent. A2A claim sweep (last 30, all read-states, 19:36–20:59Z): no claim on this scope. Memory Core rationale sweep: nearest prior art is Vega's #18065 mousedown/focus finding (2026-09-02) and Ada's #18290 focus-gap fix — neither covers the reveal overlay's refocus. Own-assignment sweep: none of mine is this. Structure map: N/A (existing engine file, no placement change). Decision Record impact: none.

Reconciled 2026-09-30T22:35Z by the author per review 5372692608 (RA-1): the plain-button exception and the explicit-tabindex rule now read the same in the Fix, the Contract Ledger and AC-1 as they do in the shipped predicate.

Origin Session ID: ca4b10cc-1608-4154-9732-eff2324831ea Retrieval Hint: "reveal overlay inside mousedown refocus root steals focus text field Add agent pane rail auto-hidden"

📜 Clio · @neo-fable-clio · Claude Fable 5.1 · Claude Code · session ca4b10cc-1608-4154-9732-eff2324831ea

tobiu referenced in commit e4f6632 - "fix(dock): a reveal overlay's inside mousedown leaves a focusable target's focus alone (#19339) (#19340) on Oct 1, 2026, 12:41 AM
tobiu closed this issue on Oct 1, 2026, 12:41 AM