# Actionability gate

The actionability gate is the precondition check tapir runs before it dispatches a pointer or key event at a widget.

- [Overview](#overview)
- [Precondition chain](#precondition-chain)
- [Live-rect dispatch (post-gate, pre-dispatch)](#live-rect-dispatch-post-gate-pre-dispatch)
- [What the gate could not prove](#what-the-gate-could-not-prove)
- [Failure reason substrings](#failure-reason-substrings)
- [Opt-out flags](#opt-out-flags)
- [Intentional gate skips](#intentional-gate-skips)
- [Cross-package implications](#cross-package-implications)
- [Related Documentation](#related-documentation)

## Overview

A refused gesture reads like this:

```
Widget ref=e5 is not actionable: not enabled
```

The pointer verbs (`tap`, `hover`, `drag`, `dblclick`, `right_click`, `triple_click`) and the
text verbs (`type`, `fill`) run the gate BEFORE synthesising the pointer or key
event. `clear`, `focus`, `scroll`, `press_key`, `select_option` and `set_checkbox` skip it. It lives in `lib/src/utils/actionability_gate.dart` and is invoked
through `ensureActionable(entry, ref: ...)`. The gate guards an agent against
firing a gesture against a widget that cannot accept it: disabled, collapsed,
off-screen, animating, or covered by another widget.

A failed gate throws a typed `TapirActionabilityException` whose `message` is
re-emitted by the VM-Service handler as
`ServiceExtensionResponse.error(extensionError, exception.message)`. Agents
parse the failure-reason substring to decide whether to re-snap, re-find,
scroll, wait, or abort.

## Precondition chain

The gate evaluates its preconditions in a fixed order (a defunct preflight, then five ordered checks). **The order is
FROZEN**: agents branch on the failure-reason
substring, so adding, removing, or reordering checks is a breaking change.

0. **Defunct (preflight)**; the entry's `Element` may have been deactivated by parent
   rebuild, route pop, or list-item recycle since the snapshot minted the ref.
   `findRenderObject()` returns null, or the framework throws on the inactive element.
   This guard runs before the five ordered checks below. Failure reasons:
   `defunct (element no longer attached to a render object)` or
   `defunct (element no longer mounted)`. The agent's recovery is to re-snap.
1. **Enabled**; the entry's `SemanticsNode` is non-null AND its
   `flagsCollection.isEnabled` is `Tristate.isFalse`. `Tristate.none` (no
   enabled flag set, e.g. plain `Text`) and `Tristate.isTrue` both pass. The
   gate only fails when the framework has explicitly marked the widget
   disabled. Synthetic entries without a captured `SemanticsNode` (for example,
   `find_by_text` results) pass through this check untouched.
2. **Zero-area rect**; the entry's `rect.width == 0 || rect.height == 0`. A
   zero-area rect cannot receive a pointer event at `rect.center` and almost
   always indicates the widget has been collapsed or detached between snapshot
   and action.
3. **Off-viewport**; the entry's rect does not intersect the active
   `FlutterView`'s visible logical viewport (recomputed every call from
   `WidgetsBinding.instance.platformDispatcher.views.firstOrNull` so window
   resizes between actions are honoured). Visible means the view minus its
   `viewInsets`: the app keeps laying out under a soft keyboard, but a
   pointer there never reaches it. When the rect's centre lies outside the
   visible viewport the gate first attempts `RenderObject.showOnScreen` to
   bring the element into view, then re-checks; it fails only when
   scroll-into-view cannot place the target inside the viewport. Skipped
   gracefully when no `FlutterView` is attached (headless test harnesses,
   multi-view race).
4. **Stable**; the entry's bounding box, sampled from the
   live `RenderBox` before and after one frame, has not drifted by more than
   0.5 logical pixels on any side. Animated widgets (sliding sheets,
   expanding tiles, page transitions) fail this gate so the agent waits for
   the animation to settle before retrying. Both samples are live, so a
   layout change that finished before the gate ran (the soft keyboard
   opening, the step 3 auto-scroll) does not trip this check. Opt out via
   `checkStable: false`.
5. **Receives events**; a hit-test at `rect.center` on the
   active view confirms the entry's render object (or a descendant) appears
   in the hit-test path. If the topmost target is anything else, an overlay,
   modal scrim, or stacked widget is swallowing the pointer. The thrown reason
   carries the obscurer's `runtimeType`. A graceful degradation accepts the
   action when the hit-test path contains only a root `RenderView` /
   `_ReusableRenderView` (Flutter Web's debug compositor sometimes pipes
   hit-tests through a snapshot view that does not mirror the live element
   subtree). Opt out via `checkReceivesEvents: false`.

## Live-rect dispatch (post-gate, pre-dispatch)

After the gate passes and BEFORE the pointer is synthesised, every pointer verb
(`tap`, `hover`, `drag` start + end endpoints, `dblclick`, `right_click`,
`triple_click`) re-resolves the target's CURRENT bounding rect via
`dispatchRectOf(entry)` and dispatches at that live centre, falling back to the
cached `entry.rect.center` only when the live rect is `null` (a sliver, a
detached / unsized render object, or a synthetic test entry). `dispatchRectOf`
reuses the same `_liveRectOf(entry.element)` helper the stable check (step 4)
measures, guarded by the `renderObject.attached` precondition Flutter's
`localToGlobal` asserts.

One exception: `tap` can retarget a merged semantics boundary. When exactly one
labelled control owns the merged label and a hit test at its centre reaches it,
the pointer goes to that control instead of the live centre (the response carries
`retargeted`). It is the default; `retarget: false` (`--no-retarget` on the CLI)
taps the live centre. `hover`, `dblclick`, `right_click`, `triple_click` and `drag`
always use the live centre. See [tapir:tap](https://anilcancakir.com/tapir/docs/commands/tapir-tap.md).

This is purely additive to the FROZEN gate: it runs after the gate passes and
before dispatch, so it touches neither the evaluation order nor any
failure-reason substring. It fixes the false-success class where a host rebuilt
the target into a shifted slot between snapshot and action: the `Element` /
`RenderObject` identity (and thus `SemanticsNode.id`) is retained across a
same-type-and-key rebuild, so the live rect is valid and the pointer lands on
the moved target instead of its stale gate-time position. A residual TOCTOU
window remains between the live-rect re-resolve and the dispatch itself (the
smallest achievable without a frame lock); the `effect` block on every
`ext.tapir.tap` response reports whether the tap produced an observable effect,
so the residual window is visible rather than silent.

## What the gate could not prove

The gate throws on a precondition it can DISPROVE. Step 5 has a third
outcome it cannot: the hit-test sometimes fails to answer at all. On Flutter
Web's debug build that is routine, because DWDS pipes hit-tests through a
snapshot view that does not mirror the live element subtree, so the path
comes back carrying only the root render view.

Breaking every valid tap on that artefact would be the worse failure, so the
gate proceeds. It used to proceed silently, and a clean pass was then
indistinguishable from a confirmed one: that is how a fill printed a green
tick four times onto a row covered by a pinned footer, with nothing in any
response to say the check had not run.

The response carries a `checks` block whenever step 5 did not confirm. The
gated verbs that emit it are `tap`, `hover`, `drag`, `dblclick`,
`right_click`, `triple_click` and `type`; `fill` runs the gate through its
type step but copies only `effect` into its own response:

```json
{
  "ref": "e7",
  "checks": {
    "receivesEvents": "indeterminate",
    "why": "hit-test returned only the root render view",
    "overlapCandidates": ["RenderPhysicalShape@0,712,390x88"],
    "hint": "The gate could not confirm the target receives the event, so this action may have landed on something else. Read the `effect` block before trusting it."
  }
}
```

| `receivesEvents` | Meaning |
|:---|:---|
| absent | Confirmed. The hit-test reached the target or a descendant. |
| `indeterminate` | The hit-test could not answer. The action was dispatched anyway. |
| `skipped` | The caller passed `checkReceivesEvents: false`. |

`overlapCandidates` is a rect scan run only on the indeterminate path: render
objects that overlap the target and paint after it, capped at five. It is
advisory, not a verdict. An overlap is not proof of occlusion, since a
transparent layer overlaps and swallows nothing, which is exactly why the
hit-test exists and why these are candidates.

The block is absent on the healthy path, so its presence is the signal.

## Failure reason substrings

The thrown message has the shape
`Widget ref=$ref is not actionable: $reason`. Agents perform substring matches
against `$reason` to branch their recovery. **The substring list is FROZEN**:

| Reason substring | Trips when | Suggested agent recovery |
|:-----------------|:-----------|:-------------------------|
| `defunct (...)` | `findRenderObject()` returns null OR Element is in `_ElementLifecycle.defunct` lifecycle state | Re-snap; the widget was deactivated. |
| `not enabled` | `flagsCollection.isEnabled == Tristate.isFalse` | Re-snap; the widget may enable later. |
| `zero rect` | `rect.width == 0 \|\| rect.height == 0` | Re-snap or re-find; layout has shifted. |
| `off-viewport` | rect does not overlap the visible viewport (view minus `viewInsets`) even after `showOnScreen` + one frame | `tapir_scroll` with `intoView: true`, then retry. |
| `not stable` | live rect drifted > 0.5 logical pixels on any side across one frame | `tapir_wait_for_network_idle` or settle delay. |
| `obscured by` | hit-test at `rect.center` resolves to a non-descendant render object first | Dismiss the obscurer (modal, scrim, overlay). |

The off-viewport reason carries the rect and viewport
(`off-viewport (rect=..., viewport=...)`); the not-stable reason carries the
maximum side delta (`not stable (rect changed by X.Xpx)`); the obscured reason
carries the obscurer's runtime type (`obscured by other widget (top=...)`).
Match on the leading substring shown above, not the trailing detail.

## Opt-out flags

`ensureActionable` exposes two opt-out parameters; both default to `true` to
match Playwright's actionability semantics:

```dart
Future<ActionabilityReport> ensureActionable(
  RefEntry entry, {
  required String ref,
  bool checkStable = true,
  bool checkReceivesEvents = true,
});
```

| Flag | Disables | When to opt out |
|:-----|:---------|:----------------|
| `checkStable: false` | the stable precondition (4) | widget tests that fabricate synthetic `RefEntry` rects which do not match the live render-object geometry. |
| `checkReceivesEvents: false` | the receives-events precondition (5) | the same widget-test scenarios, plus environments where the platform compositor swallows hit-tests. |

The pointer verbs, `type` and `fill` expose the same two flags as `checkStable` and
`checkReceivesEvents` inputs (see [tool-reference.md](https://anilcancakir.com/tapir/docs/mcp/tool-reference.md)); both default to `true`.

## Intentional gate skips

Some action handlers do NOT route through the actionability gate. These skips
are deliberate and listed here so contributors do not add the gate "for symmetry":

- **`scroll`**; operates on the parent `Scrollable` rather than the ref
  target. Gating the ref would refuse scrolls against widgets that are
  off-viewport, which is exactly the scenario `tapir_scroll` is meant to fix.
- **`select_option`**; calls the dropdown's `onChanged` callback directly
  instead of tapping, so no pointer event leaves the VM and there is nothing
  for the gate to check.
- **`press_key`**; targets the currently focused widget, not a ref. The gate
  contract requires a `RefEntry`; the focused widget may not have a token.

## Cross-package implications

The gated actions share a common error shape. `TapirActionabilityException`
is caught by the VM-Service handler and re-emitted via
`ServiceExtensionResponse.error(extensionError, wrapErrorDetail(...))`. The wire
error detail is a JSON string with a `message` field (the flat message, reason
included) and an `envelope` field (a machine-readable `type`, `widget_path` and
`suggestions`). Substring matching on the reason still works because the reason
sits verbatim inside `message`; consuming agents (Claude Code, Cursor,
Windsurf via the MCP tool surface) branch on it.

The contract guarantee is:

- The reason substrings are frozen and listed above.
- The evaluation order is frozen so substring branching stays deterministic.
- The exception is always typed; the wire format is always the JSON string described above.
- Agents re-snap or re-find on failure. The gate never silently retries; the
  cost of a silent retry on an animating or obscured widget is a flaky test
  the agent cannot diagnose.

A change to any of these guarantees requires a coordinated bump across
`tapir` (this package), `magic` (whose `MagicTapirIntegration` adds
enrichers to the snapshots that mint the refs the gate guards) and `wind_kit`
(whose diagnostics reach the same snapshots through
`wind_kit_diagnostics_contracts`). Treat the gate's public contract as
load-bearing across the ecosystem.

## Related Documentation

- [tool-reference.md](https://anilcancakir.com/tapir/docs/mcp/tool-reference.md): the `checkStable` and `checkReceivesEvents` inputs and the `effect` block of every gated verb.
- [frame-production.md](https://anilcancakir.com/tapir/docs/reference/frame-production.md): why a hidden window or tab produces no frames for the stable check to sample.
- [magic-integration.md](https://anilcancakir.com/tapir/docs/plugins/magic-integration.md): the Magic enrichers that annotate the refs the gate guards.
