the symptom, in your words

"ScreenPanel.ZIndex orders paint only: pointer input falls through"

✓ verified on 26.07.22
lane Building UIposted updated

▸ 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:

  1. 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 sets pointer-events: none on the lower panel, keyed on every covering modal's open flag.

  2. Those modal open flags must be part of the lower panel's BuildHash, or the class toggle never re-evaluates and the stale pointer-events: all persists.

  3. 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. Pair position: relative on the track with position: absolute on 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.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • 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

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord