"ScreenPanel.ZIndex orders paint only: pointer input falls through"
▸ SYMPTOM
A full-screen modal renders correctly on top of other UI (its ScreenPanel.ZIndex is higher), and some of its controls work. But clicks in regions where the lower root also has hit area trigger the underlying screen's buttons instead of the modal's. The modal looks broken: it draws correctly but cannot receive input in overlapping regions.
▸ CAUSE
ScreenPanel.ZIndex controls paint order only. Pointer input routing does NOT consult cross-root z-ordering. Each ScreenPanel root maintains its own independent hit-test surface, and a lower-ZIndex root whose panel has pointer-events: all keeps intercepting pointer events in its active region regardless of what is drawn on top of it.
This means a higher-ZIndex modal's own pointer-events: all scrim cannot block the lower root's input. The two roots' hit surfaces are parallel and independent: painting one on top of the other does not suppress the one underneath.
▸ FIX
Use cooperative de-arming instead of relying on z-order for input:
-
The lower root must drop its own hit surface while any modal that can cover it is open. Add a computed class (e.g.
under-modal) that setspointer-events: noneon the lower panel, keyed on every covering modal's open flag. -
Those modal open flags must be part of the lower panel's
BuildHash, or the class toggle never re-evaluates and the stalepointer-events: allpersists. -
Every new full-screen modal added over an armed full-screen surface must join that surface's de-arm condition. The modal's own scrim cannot protect it: only the lower root removing itself from the input surface does.
▸ THE WIDER TRAP: ANY FULL-SCREEN PANEL ROOT SWALLOWS CLICKS
The same swallow happens with no z-index and no modal in play at all (generalized 26.07.22). A PanelComponent's root element covers the entire screen and hit-tests every pixel by default. A panel stylesheet that never declares pointer-events on its root renders pixel-perfect (it compiles clean and looks correct in a screenshot), but the full-screen root still swallows every click meant for anything underneath, including the panel's own card in a second ScreenPanel. No z-index trick and no open modal is required to trigger it, and the failure is completely silent: dotnet build and editor compile cannot catch it; only a human clicking finds it.
The load-bearing idiom (present in every working panel surveyed) is mandatory boilerplate for every new panel stylesheet:
- the full-screen root sets
pointer-events: none; - the card re-enables with
pointer-events: all; - interactive children (tabs, buttons, slider tracks) are
all; - slider fill elements are explicitly
none: a fill that takes the pointer makes drag math measure against the fill instead of the track and values jump. Pairposition: relativeon the track withposition: absoluteon the fill so the fill grows without stretching the row.
Two ScreenPanels coexist fine when both follow the idiom: the stacking was never the problem, the missing root rule was. Human-click-test every new panel, since this regression class is invisible to every automated gate.
An empty root still swallows
The swallow fires even when the offending root's @if drew nothing that frame. A common topology is several PanelComponents mounted as siblings on one ScreenPanel (several screens sharing a canvas), each declaring pointer-events: all on its root. An empty screen whose @if had nothing to draw at the time still fills the screen and hit-tests every pixel, because paint/hit order is mount order and the root element exists whether or not its conditional content does. A menu's rows sitting later in the mount stack never see a press, while every automated gate stays green: the stylesheets compile clean and the screen renders pixel-perfect through a full review pass. The same idiom fixes it: every panel root starts pointer-events: none, interactive elements opt back in with all.
Diagnostic idiom that finds it fast: a ui_hit x y ConCmd that walks every mounted panel and prints, in paint order, each candidate whose box contains the point and whose resolved Panel.ComputedStyle.PointerEvents is not none. It is built entirely from the engine's own hit geometry (Panel.IsInside) and its own resolved style, with only the paint-order ranking added on top: cheap, permanent, and reusable on any panel stack.
▸ WHY IT WORKS
The engine processes each ScreenPanel as an independent input root. There is no global z-ordered input compositor that would let a higher panel shadow a lower one's hit tests. By having the lower root voluntarily remove itself from the input surface (pointer-events: none), the modal's own hit surface becomes the only active one in its region: input routes correctly without requiring the engine to implement cross-root z-aware input dispatch.
The BuildHash requirement is the subtle part: razor panels only re-evaluate their tree when the hash changes, so a flag that controls pointer-events but is not part of BuildHash creates a stale-tree bug where the class never updates even though the modal state changed.
- Further generalized (26.07.22): the swallow fires even when the offending root's @if drew nothing that frame, because paint/hit order is mount order and the root element exists regardless of its conditional content. Added the ui-hit diagnostic ConCmd.
- Generalized (26.07.22): the same swallow happens with no z-index or modal at all. A panel whose stylesheet never declares pointer-events on its full-screen root eats every click silently. Added the mandatory root/card pointer-events idiom.
- Published