feat(interaction): name the covering element in covered refusals
Summary
A target_covered refusal used to say only "covered by another visible element" even though the
occlusion scan had already found the culprit. The annotation pass now publishes the covering node as
a bounded, non-actionable coveredBy description (presented role, clipped label, rect) on the
covered node, and both refusal producers surface it as a typed error.details.coveredBy and name it
in the hint — the Playwright "intercepts pointer events" shape. One helper,
coveredInteractionDetails, owns the whole detail shape, so the find ... focus refusal now carries
the typed reason: target_covered it used to omit. Covers the scan cannot name publish no
description at all rather than a misleading one. Snapshot unchanged-metadata compares coveredBy by
value. Help: manual-qa recovery gains the covered-refusal line; snapshots.md documents the node
field.
Closes #3488
Before → after, click @e2 with a tab bar over the button (details):
- "hint": "Use a different visible target, scroll it clear of the overlay, ..."
+ "hint": "Covered by the tab-bar \"Media\" at (0, 760 390x84). Dismiss or close that element, ...",
+ "coveredBy": { "kind": "tab-bar", "label": "Media", "rect": { "x": 0, "y": 760, "width": 390, "height": 84 } }
Device evidence
Run on the iPhone 18 Pro simulator (BDA9B772-A0A9-46A4-AC91-C9107FAE0277, iOS 27.0) against
com.callstack.agentdevicelab from a pnpm build of this branch's runtime code, private state dir
/private/tmp/feat-3495-state. Only two non-runtime hunks landed after the run: a doc-comment reword
and a test-local rename.
Surface: home tab → Open confirmation alert, a real UIAlertController drawn over the live lab
screen. snapshot -i --json marks 14 of 38 interactive nodes:
{
"ref": "e9",
"kind": "text",
"label": "Good for wait, get, and state assertions.",
"hittable": false,
"interactionBlocked": "covered",
"coveredBy": {
"kind": "group",
"label": "Confirm catalog refresh",
"rect": { "x": 41, "y": 327, "width": 320, "height": 248 }
},
"presentationHints": ["covered"]
}
Unscoped snapshot --json on the same screen: 66 nodes, 21 carrying the pair. click @e9:
{
"code": "COMMAND_FAILED",
"message": "Ref @e9 is covered by another visible element and cannot be tapped safely",
"hint": "Covered by the group \"Confirm catalog refresh\" at (41, 327 320x248). Dismiss or close that element, scroll the target clear of it, or inspect with snapshot/screenshot before retrying.",
"details": {
"reason": "target_covered",
"ref": "@e9",
"interactionBlocked": "covered",
"coveredBy": {
"kind": "group",
"label": "Confirm catalog refresh",
"rect": { "x": 41, "y": 327, "width": 320, "height": 248 }
},
"dispatched": "no"
}
}
find "Session health" focus and press label="Session health" (the selector path) refuse with the
identical reason, coveredBy and hint; the selector path adds
"selector": "label=\"Session health\"". click label="Keep browsing" dismisses the alert, after
which the capture reports 0 blocked nodes and the same click @ref taps.
What the real tree taught us about the cover's identity
The cover this capture returns is a generic container: the alert's view publishes as
type: "Group", role: "_UIAlertControllerPhoneTVMacView", so its presented kind is group. It
became an overlay candidate at all only through its role (the alert fragment), never its kind.
What makes the hint meaningful is the alert's title arriving as that container's accessible label.
Strip that label and the same live node would have refused with Covered by the group at (41, 327 320x248) — a faceless box nobody would recognize or dismiss.
So describeCoveringNode returns no description for an anonymous element/group/other cover.
The node still publishes interactionBlocked: "covered", and the refusal keeps its generic recovery
— the same shape a backend-policy cover already produces. A named kind (dialog, sheet,
tab-bar) still describes itself with no label to lean on. Pinned in snapshot-occlusion.test.ts
from these live node shapes; the case fails without the guard.
An ancestor walk to a "more meaningful" node was considered and rejected: on this tree the cover's
parent is the Application node, so it would describe the app rather than the alert.
Consolidation
coveredInteractionDetails in @agent-device/selectors/interaction-error, beside the targetCovered
reason it keys, owns the whole detail shape (reason, hint, selector, ref, interactionBlocked,
coveredBy) including the one-line rendering of the cover used in the hint.
buildCoveredInteractionError and rejectCoveredFindMatch both build through it, which also gives
the find refusal the typed reason: target_covered it used to omit.
The rendering lives in that module rather than in kernel/snapshot because CI's eager-closure gate
holds that subpath's import closure to its own module alone: a covered refusal is the rendering's only
consumer, and importing the snapshot presentation for one string made every importer of the reason
vocabulary evaluate it eagerly (2 modules where the merge-base had 1). kernel/snapshot keeps the
published data shape and its label bound; the hint's cases moved with the behavior.
Validation
pnpm check:affected --run under the host lock at this head. New tests observe the annotation
(packages/capture-kit/src/snapshot-occlusion.test.ts, including the live-capture family), the
shared detail (packages/selectors/src/interaction-error.test.ts), the runtime refusal through
device.interactions (ref path, covered vs policy-covered vs unnameable), the find focus refusal
and its typed reason, and unchanged-metadata comparison; they fail without the implementation
(verified by reverting it). Interaction-contract integration suites pass unchanged.