Repository navigation
find focus/type dispatch the focus without the #2589 keyboard guard #2622
Description
Purpose
find <locator> focus and find <locator> type <text> dispatch the focus themselves, so they never pass the shared pipeline door where the #2589 keyboard guard runs. A match whose tap point sits behind the visible keyboard is focused anyway, activating a key instead of the target — the exact misfire #2589 was filed for.
Found while reviewing #2602 (src/daemon/interaction/internal/find.ts#L340-L367, at 69c67c7). It is outside the ADR 0011 matrix rows the PR covers, and the layering scan blocks the one-line fix, so the seam is tracked here.
Current behavior
dispatchFocusForFindMatch (src/daemon/interaction/internal/find.ts:340) runs:
rejectCoveredFindMatch— the same-window occlusion refusal, so this seam does carry one pre-action guard;centerOfRect(match.resolvedNode.rect)— the point it then dispatches;expireRefFrame(session)— the ADR 0014 side-effect seam;executeFocusPoint(...).
assertTapTargetClearOfVisibleKeyboard (src/commands/interaction/runtime/keyboard-occlusion.ts) is called from runInteractionPipelineStages (src/commands/interaction/runtime/resolution.ts:678) and the native-ref preflight only. find click / find fill re-enter the interaction leaf and are covered; find focus / find type do not.
A keyboard is invisible to step 1 by construction (its own system surface, never a covering sibling) and passes the viewport rule, so nothing between the match and the device refuses this point.
Required behavior
The tap point this seam dispatches is refused before the frame is expired, with the shared decision and the shared failure:
- same decision as the acting paths:
resolveKeyboardTapOcclusionagainstmatch.nodes(the tree find matched against) and the point from step 2; - same failure: reason
tap_keyboard_occludes_target,ref,rect,keyboardFramedetails, and the passive-verb message the acting guards use forfocus; - the refusal stays ahead of
expireRefFrame, so a refusedfind focusleaves the captured frame pinnable, as the covered refusal already does; - one shared implementation, reached through a dependency direction the layering scan accepts (see below) — not a second copy of the band reading or the error shape.
Why the obvious patch is rejected
Calling the guard from find.ts is four lines and passes every test:
try {
assertTapTargetClearOfVisibleKeyboard({
nodes: match.nodes,
node: match.resolvedNode,
action: 'focus',
label: `Ref ${match.ref}`,
tapPoint: coords,
});
} catch (error) {
return { ok: false, error: normalizeError(error) };
}It fails R2 commands-floor: daemon/ must not import commands/ … if two zones need the same rule, put the rule below both of them. A rule with two zone callers belongs at the shared Node stage door, so this issue owes a placement decision rather than a guard.
Completion conditions
-
find <locator> focusandfind <locator> type <text>refuse a target whose tap point sits behind a docked keyboard, quoting the keyboard frame, and never reachfocusPoint. - A refused seam leaves
refFrameState(session)atactive. - The refusal is produced by the same code the acting paths call;
rg -n "behind the visible keyboard" src/names one construction site. -
pnpm run check:layeringpasses. - A matrix row exists for this seam and every cell is classified — see below.
-
pnpm check:affected --runis green on the commit being pushed, perdocs/agents/pull-requests.md.
Matrix row
packages/contracts/src/interaction-guarantees.ts has no path id for this seam: runtime-ref lists press, click, fill, longpress, hover, and INTERACTION_PATH_IDS has nothing for a mutating find that dispatches on its own. Adding a path means classifying all InteractionGuarantee cells (compile-enforced), so the row is part of the work, not a formality. Starting points to verify rather than copy:
| Guarantee | Candidate cell |
|---|---|
occlusion |
runtime — src/daemon/interaction/internal/find.ts#rejectCoveredFindMatch, served from runNodePipelineStages(SELECTOR_PIPELINE_POLICIES.findAct, …) |
keyboardOcclusion |
runtime — whatever shared symbol this issue lands |
parentOwnedTouchPoint, offscreen, nonHittable |
read SELECTOR_PIPELINE_POLICIES.findAct and classify what that policy actually runs |
disambiguation, errorTaxonomy, resolutionDisclosure |
find owns its own locator matching; classify against resolveFindMatch, not the runtime rows |
responseConstruction, responseIdentity, verifyEvidence, settleObservation |
this seam returns executeFocusPoint's shape plus recordFindAction; classify what it can and cannot echo |
Placement options
- Shared Node stage — make the guard a stage of the node pipeline in
@agent-device/selectors, next toselector-pipeline.ts#runNodePipelineStagesandinteraction-touch-point.ts, and declare it inSELECTOR_PIPELINE_POLICIES.findAct. Then bothfindand the interaction leaf get it at the door they already run through, and the matrix cell reads likeocclusion's. Costs:runNodePipelineStagesstages must carry the action, the caller's label, and the resolved tap point, which is a wider stage contract than any current stage needs. - Move the rule below both zones — relocate
keyboard-occlusion.tsand the action vocabulary it needs (InteractionAction+interactionVerb, currently in the commands zone) into a package both zones already import. Costs:keyboard-occlusion.test.tscurrently drives the guard throughcommands/interaction/runtime/__tests__/test-utils, so the test splits between the shared unit assertions and the pipeline-level ones.
Both stay within the interaction family; neither is a rename-only move.
Notes
- The unit and seam tests for the patch above are written and pass with the guard wired in (two cases in
src/daemon/interaction/internal/__tests__/find.test.ts, using the captured iPhone 17 Pro keyboard tree fromtest/integration/interaction-contract/fixtures.ts#keyboardCoveredTabBarSnapshot). They are recoverable from this issue's patch snippet plus that fixture. - Reproduction without a unit test: iOS Simulator, keyboard up, then
find <label of an element inside the keyboard band> focuson a target that is not itself a keyboard control. - Dependency: feat(contracts): name the keyboard band that owns a tap point #2601 (
packages/contracts/src/tap-keyboard-occlusion.ts) provides the decision this seam must call.
Activity