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:
- 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.
- 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:
- 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.
- 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).
- 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.
- Keep the focus-hold contract for inside interactions: an iframe INSIDE the revealed pane must not dismiss its own reveal.
Acceptance Criteria
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"
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, oroverlayFocusLeave, whichmanager.Focusderives from afocusoutthat nofocusinfollows withinmaxFocusInOutGap(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, noclick, nofocusin. What the parent can see is exactly two things, and today the reveal reads neither:focusoutwithrelatedTarget: null, the parentwindowfiresblur, anddocument.activeElementbecomes the<iframe>element. The engine forwardsfocusout(src/main/DomEvents.mjs:523), somanager.Focusshould reachfocusLeaveafter the gap — the consumer red says the chain does not complete here. Either the frame's page cancels themousedowndefault (an editor managing its own caret typically does), so focus never leaves the parent, or thefocusout→ gap →focusLeavepath drops the frame case. The first AC is to measure which, on an engine fixture.<iframe>element lives in the parent document; the overlay receivesmouseleavewhen the pointer moves onto it, and the frame element itself receivesmouseenter. 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'smousedownlistener (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 onemousedownlistener 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 inrevealed-focused;outsideClick()→escape().src/dashboard/dock/interaction/RevealOverlay.mjs:184-190, 388-404— rootmousedown(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—onFocusoutschedulesfocusLeaveaftermaxFocusInOutGapunless afocusinarrives.src/main/DomEvents.mjs:516-525— forwardsfocusin/focusout; there is nowindowblurforwarding.src/form/field/Picker.mjs— the engine's existing outside-pointer idiom (main-viewmousedown+ path check).test/playwright/component/apps/dock-splitter(DockSplitterIframePane.spec.mjs); rail fixture:apps/dock-rail.The Fix
Measured first, then one mechanism per signal:
apps/dock-railor a newapps/dock-rail-iframe), two frame contents — one whose document leaves themousedowndefault alone, one that cancels it — and the assertion "a click into the frame dismisses the reveal" for both.focusout→focusLeavepath hold for the frame case; if the worker never sees the leave, forward the parentwindowblurfromNeo.main.DomEventsas a focus-leave signal whose target is the<iframe>element (document.activeElement).mousedownon 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.Acceptance Criteria
dev(both frame variants), and the measurement of which parent signals fire is recorded on the ticket.mousedowndefault.RevealStateMachine's state table documents the new inputs; existing dock rail component and Workstation e2e arms stay green.Out of Scope
Avoided Traps
windowbluralone: covers only frames that take focus; the frame that cancels the mousedown default needs the pointer-crossing arm.Related
body.neo-drag-active iframe {pointer-events: none}), the sibling precedent for treating frames as opaque boundaries.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"