LearnNewsExamplesServices
Frontmatter
id18067
titleA rail reveal stays open when the click lands inside an iframe pane
stateClosed
labels
bugaicore
assigneesneo-fable
createdAtSep 2, 2026, 12:13 PM
updatedAtSep 2, 2026, 2:50 PM
githubUrlhttps://github.com/neomjs/neo/issues/18067
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 2:50 PM

A rail reveal stays open when the click lands inside an iframe pane

neo-opus-vega
neo-opus-vega commented on Sep 2, 2026, 12:13 PM

Context

A click-born rail reveal is revealed-focused (dashboard/dock/interaction/RevealStateMachine.mjs): the rail focuses the overlay root, and from then on the reveal leaves only on an explicit input — Escape, a re-click of its rail tab, or overlayFocusLeave, which manager.Focus derives from a focusout that no focusin follows within maxFocusInOutGap (src/manager/Focus.mjs:149-173). Pointer-leave is deliberately inert while focused ("a focused reveal never auto-dismisses").

Observed in a consumer workspace whose dock hosts an editor pane rendered in an iframe (real Chrome, and reproduced headless in the workspace's own e2e suite, 2026-09-02): with a reveal open, a click into the iframe leaves the reveal open. The control in the same suite — a click on another pane's tab header — dismisses it, and after #18065 a click into a grid's empty viewport dismisses it too. The iframe is the one remaining target that does not count as "moving on".

The Problem

An iframe is a second document. Nothing that happens inside it reaches the parent's event surfaces: no mousedown, no click, no focusin. What the parent can see is exactly two things, and today the reveal reads neither:

  1. Focus crossing into the frame. When the frame's document takes focus, the parent's focused element receives focusout with relatedTarget: null, the parent window fires blur, and document.activeElement becomes the <iframe> element. The engine forwards focusout (src/main/DomEvents.mjs:523), so manager.Focus should reach focusLeave after the gap — the consumer red says the chain does not complete here. Either the frame's page cancels the mousedown default (an editor managing its own caret typically does), so focus never leaves the parent, or the focusout → gap → focusLeave path drops the frame case. The first AC is to measure which, on an engine fixture.
  2. The pointer crossing the frame element. The <iframe> element lives in the parent document; the overlay receives mouseleave when the pointer moves onto it, and the frame element itself receives mouseenter. That is the only signal the parent gets when the frame swallows focus.

The engine has no "outside pointer" reading for reveals at all: RevealOverlay's mousedown listener (RevealOverlay.mjs:186) is component-scoped and only refocuses the root for inside clicks; RevealStateMachine#outsideClick() exists but its single caller is the maximize path (Workspace.mjs#dismissDockRevealOverlays). Form pickers solve the same problem with one mousedown listener on the app's main view (src/form/field/Picker.mjs:9, outsidePointerListener).

The Architectural Reality

  • src/dashboard/dock/interaction/RevealStateMachine.mjs — state table; overlayFocusLeave() acts only in revealed-focused; outsideClick() → escape().
  • src/dashboard/dock/interaction/RevealOverlay.mjs:184-190, 388-404 — root mousedown (inside only), onFocusEnter / onFocusLeave → revealFocusEnter / revealFocusLeave.
  • src/dashboard/dock/interaction/Rail.mjs:279-281, 454-468 — wires the overlay events into the machine.
  • src/manager/Focus.mjs:149-173 — onFocusout schedules focusLeave after maxFocusInOutGap unless a focusin arrives.
  • src/main/DomEvents.mjs:516-525 — forwards focusin / focusout; there is no window blur forwarding.
  • src/form/field/Picker.mjs — the engine's existing outside-pointer idiom (main-view mousedown + path check).
  • Engine fixture with an iframe pane: test/playwright/component/apps/dock-splitter (DockSplitterIframePane.spec.mjs); rail fixture: apps/dock-rail.

The Fix

Measured first, then one mechanism per signal:

  1. Fixture + red-first witness: a rail item beside an iframe pane (extend apps/dock-rail or a new apps/dock-rail-iframe), two frame contents — one whose document leaves the mousedown default alone, one that cancels it — and the assertion "a click into the frame dismisses the reveal" for both.
  2. Focus crossing (frame takes focus): make the focusout → focusLeave path hold for the frame case; if the worker never sees the leave, forward the parent window blur from Neo.main.DomEvents as a focus-leave signal whose target is the <iframe> element (document.activeElement).
  3. Pointer crossing (frame swallows focus): the rail registers the Picker-style outside mousedown on the app main view while a reveal is open (covers every non-frame outside target without depending on focusability), and treats the pointer entering an <iframe> element outside the overlay as an outside interaction — RevealStateMachine#outsideClick() is the existing input for both.
  4. Keep the focus-hold contract for inside interactions: an iframe INSIDE the revealed pane must not dismiss its own reveal.

Acceptance Criteria

  • AC-1 Engine fixture reproduces the consumer red: with a reveal open, a real click into a sibling iframe pane leaves the overlay open on dev (both frame variants), and the measurement of which parent signals fire is recorded on the ticket.
  • AC-2 After the fix the same click dismisses the reveal for a frame that takes focus.
  • AC-3 After the fix the same click dismisses the reveal for a frame that cancels the mousedown default.
  • AC-4 A click, or focus, inside an iframe hosted by the REVEALED pane keeps the reveal open (focus-hold preserved).
  • AC-5 RevealStateMachine's state table documents the new inputs; existing dock rail component and Workstation e2e arms stay green.

Out of Scope

  • #18065 (grid mousedown default) — landed separately; this ticket starts from its green.
  • Frames in popup windows (cross-window reveals) — the reveal is window-local.
  • The editor product inside the consumer's frame — the engine sees only the frame boundary.

Avoided Traps

  • Dismissing on pointer-leave while focused: reintroduces the flicker the focus-hold contract was written to prevent; the signal must be an outside INTERACTION, not a hover.
  • Listening inside the frame: cross-document, often cross-origin in deployment — not the engine's surface.
  • Forwarding window blur alone: covers only frames that take focus; the frame that cancels the mousedown default needs the pointer-crossing arm.

Related

  • #18065 / PR #18066 — the grid half of "a click outside the reveal is leaving it".
  • #17317 — the inverse case (inside clicks on non-focusable content must NOT dismiss), whose contract this ticket must keep.
  • #17887 — the iframe shield during drags (body.neo-drag-active iframe {pointer-events: none}), the sibling precedent for treating frames as opaque boundaries.
  • #18028, #18039 — rail reveal containment and pin round trip.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T10:11Z; no equivalent found (#17317 is the inverse contract). A2A claim sweep (last 30 messages, all read-states): no overlapping [lane-claim].

Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25

Retrieval Hint: "rail reveal iframe click focusout window blur outside mousedown dismiss"