237. The pointer’s state follows the frame, not only the pointer
Date: 2026-09-05
Status
Accepted. Closes the TODO.md entry opened by
ADR-0057, and corrects a claim
ADR-0059 left in the code.
Context
The entry stated the gap and its fix in two sentences:
Nothing recomputes the cursor when the tree changes under a still pointer. A widget that becomes disabled without the pointer moving keeps the shape it had. The fix is re-running
cursorAtafter each paint against the last known position; it is worth doing when something can actually change that way.
Something can. controls.css gives every disabled control cursor: not-allowed
in two places, and the sequence that reaches it is the ordinary one: a button
disables itself in its own press handler, or a form disables its submit as the
user types, and the pointer does not move because the person deciding whether to
click is the person holding still.
The router only ever recomputed the cursor from pointerMoved, so the shape was
a function of the last motion rather than of the last frame. Everything
needed to fix it was already there — updateRegions runs after every paint and
holds the rectangles — except a place to remember where the pointer is. The three
position fields the router had are gesture-scoped: pressOriginX/Y span a
press-to-release and are NaN outside one, which is exactly what makes them
useless here.
Measuring it turned up a second half the entry did not name, and a comment that
was actively wrong. mark says:
Only setting is suppressed. Clearing always goes through, so a control that was hovered before it became disabled does not keep the state — which is a real sequence, because a button commonly disables itself in its own press handler while the pointer is still over it.
The control does keep the state. Clearing is not suppressed, but nothing calls
it: updateHover is mark’s only caller for :hover, and it returns early when
the element under the pointer has not changed. So the hover wash survived every
subsequent move within the control and went away only when the pointer left it
— on the exact sequence the comment names as the reason it is safe.
docs/design-system.md §2.1 makes that non-discretionary: a disabled control
that still lightened under the pointer would be telling the user it can be used.
Which makes it one defect rather than two. Fixing only the cursor would have left
a control drawing its hover wash while its cursor said not-allowed — a state
more confusing than either mistake alone.
Decision
The pointer’s state is re-asked once per frame, against the position the pointer is actually at.
- A fourth position field,
pointerX/pointerY, and it is the only one that outlives a gesture. Set from every entry point that carries a position — moved, pressed, released, wheeled — rather than frompointerMovedalone, so a window whose first event is a click is not left with nowhere to ask about. NaNis the whole of “we do not know”, and it means it twice: before the pointer has ever arrived, and afterpointerExited, which is another window’s pointer or none at all.updateRegionsskips the recompute in both cases rather than askingcursorAtabout a point the pointer is not at.updateCursor’s capture freeze is reached through, not around. A repaint during a drag does not thaw the shape: the recompute goes through the same method, which returns early while something is captured.restate()re-asserts:hoverand:activeover the hovered and pressed chains, and its whole implementation ismark(…, true)— becausemarkalready knows the rule. It turns a set into a clear on a disabled element, so re-asserting what the pointer is over sets the state where the control is live and takes it away where it is not, in one call with no second branch.- No
ENTEREDorEXITEDis emitted. Nothing entered or exited anything: the pointer has not moved and the element under it is the one that was there. That is the same linemarkitself draws — this is about what a control looks like, not about what it is told.
Alternatives considered
- Recompute from the widget side, when a control learns it is disabled. It
needs every control to know, and the states are the router’s — the argument
ADR-0059 already made for putting the
:hoverrefusal inmarkrather than in each widget. - Call
updateHoverfromupdateRegionsinstead ofrestate. It is fewer lines and it would fireENTERED/EXITEDon a repaint, because the early return it depends on is keyed on the element rather than on the state. A tooltip opening because a list repainted is a worse bug than the one being fixed. - Recompute only when the frame’s regions differ from the last. The
comparison costs more than the recompute:
cursorAtis a walk of the rectangles under one point, andsetCursorandsetPseudoClassare both already edge-triggered, so an unchanged frame is silent without help. - Track the position in
Windowand pass it back in. The router is the thing that already holds “where the pointer is” for hover, capture and hit testing; a second copy a window kept in sync would be a second thing to get wrong. - Fix the cursor only, and record the
:hoverhalf as a new entry. It is the narrower reading of the entry, and it ships a control whose cursor and whose paint disagree. §2.1 already says what the answer is, so there was no decision left to defer.
Consequences
- This runs once per frame rather than once per motion, which makes the
edge-triggering in
setCursorandsetPseudoClassload-bearing in a way it was not before. A 120 Hz repaint over a still pointer is 120 comparisons and no platform calls; a test asserts exactly that. - Eight cases in
CursorTest, under a new group. Five for the cursor — the repaint that recomputes, the unchanged frame that is silent, the frame before the pointer has arrived, the frame after it has left, and the drag that stays frozen — and three for the pseudo-classes::hoverlost on disabling,:hoverreturned on re-enabling, and:activelost mid-press. Three of them were checked against the old code and fail on it. Switchableis a new test widget, because a record cannot be disabled after the tree holding it is built and the whole point is that the widget changes under a pointer that does not.mark’s comment is now true. It was describing an intended behaviour as an achieved one, which is the kind of comment that stops the next person looking.- A window that never sees a pointer pays nothing: the
NaNcheck is the first thing the frame hook does.